hlassiege/panopliepublic Fork
⑂ 9d90581
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): redesign Home, Projects, Catalog, Presets, Sync, Marketplaces, Settings and onboarding from the Claude Design mockup

Also adds catalog clone and project discovery endpoints, and fixes the flaky catalog git tests (fewer git spawns, no optional locks on status).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
hlassiege committed 2026-10-23T14:49:45+02:00 Browse files
9d90581 parent: eb24262
modified packages/core/src/api.ts +25 -0
@@ -13,6 +13,7 @@
1313 * DELETE /api/projects/:id -> 204 (only forgets the project, never touches its files)
1414 * GET /api/projects/:id/inventory -> ScopeInventory
1515 * GET /api/fs/list?path=<abs> -> FsListing (no path: roots, i.e. drives on Windows + home)
16+ * GET /api/fs/discover?root=<abs>&depth=<1..4> -> FsDiscovery (folders with agent config under root; capped in count and time)
1617 * GET /api/files?path=<abs> -> FileContent (read-only; only inside the home agent folders or an added project)
1718 *
1819 * Errors: non-2xx responses carry an `ApiError` body.
@@ -55,6 +56,21 @@ export interface FsListing {
5556 entries: FsEntry[]
5657 }
5758
59+/** A folder found by `GET /api/fs/discover`. */
60+export interface DiscoveredProject extends FsEntry {
61+ /** Agent config files / folders found in it (`.claude`, `AGENTS.md`...). */
62+ markers: string[]
63+}
64+
65+export interface FsDiscovery {
66+ root: string
67+ /** Effective depth after clamping. */
68+ depth: number
69+ entries: DiscoveredProject[]
70+ /** True when the result cap or the time limit stopped the search early. */
71+ truncated: boolean
72+}
73+
5874 export interface FileContent {
5975 path: string
6076 content: string
@@ -75,6 +91,8 @@ export interface FileContent {
7591 * Catalog
7692 * GET /api/catalog -> CatalogResponse
7793 * POST /api/catalog/init -> CatalogResponse (creates layout + git init if not a repo)
94+ * POST /api/catalog/clone CloneCatalogBody -> CatalogResponse (git clone into the catalog path, which must be absent or empty;
95+ * `path` also becomes settings.catalogPath; 400 with git's message on failure)
7896 * GET /api/catalog/items/:type/:name -> CatalogItemDetail
7997 * PUT /api/catalog/items/:type/:name SaveItemInput -> CatalogItem (create or update; rename via previousName)
8098 * DELETE /api/catalog/items/:type/:name -> 204 (409 if used by a scope config, unless ?force=1)
@@ -130,6 +148,13 @@ export interface Settings {
130148 autoSync: boolean
131149 }
132150
151+export interface CloneCatalogBody {
152+ /** Git remote URL (SSH or HTTPS) of the catalog repository. */
153+ url: string
154+ /** Absolute target folder; defaults to the current catalog path. Saved as `settings.catalogPath` once the clone succeeded. */
155+ path?: string
156+}
157+
133158 export interface CatalogResponse {
134159 snapshot: CatalogSnapshot
135160 git: GitStatus
@@ -13,6 +13,7 @@
13 * DELETE /api/projects/:id -> 204 (only forgets the project, never touches its files)13 * DELETE /api/projects/:id -> 204 (only forgets the project, never touches its files)
14 * GET /api/projects/:id/inventory -> ScopeInventory14 * GET /api/projects/:id/inventory -> ScopeInventory
15 * GET /api/fs/list?path=<abs> -> FsListing (no path: roots, i.e. drives on Windows + home)15 * GET /api/fs/list?path=<abs> -> FsListing (no path: roots, i.e. drives on Windows + home)
16+ * GET /api/fs/discover?root=<abs>&depth=<1..4> -> FsDiscovery (folders with agent config under root; capped in count and time)
16 * GET /api/files?path=<abs> -> FileContent (read-only; only inside the home agent folders or an added project)17 * GET /api/files?path=<abs> -> FileContent (read-only; only inside the home agent folders or an added project)
17 *18 *
18 * Errors: non-2xx responses carry an `ApiError` body.19 * Errors: non-2xx responses carry an `ApiError` body.
@@ -55,6 +56,21 @@ export interface FsListing {
55 entries: FsEntry[]56 entries: FsEntry[]
56 }57 }
57 58
59+/** A folder found by `GET /api/fs/discover`. */
60+export interface DiscoveredProject extends FsEntry {
61+ /** Agent config files / folders found in it (`.claude`, `AGENTS.md`...). */
62+ markers: string[]
63+}
64+
65+export interface FsDiscovery {
66+ root: string
67+ /** Effective depth after clamping. */
68+ depth: number
69+ entries: DiscoveredProject[]
70+ /** True when the result cap or the time limit stopped the search early. */
71+ truncated: boolean
72+}
73+
58 export interface FileContent {74 export interface FileContent {
59 path: string75 path: string
60 content: string76 content: string
@@ -75,6 +91,8 @@ export interface FileContent {
75 * Catalog91 * Catalog
76 * GET /api/catalog -> CatalogResponse92 * GET /api/catalog -> CatalogResponse
77 * POST /api/catalog/init -> CatalogResponse (creates layout + git init if not a repo)93 * POST /api/catalog/init -> CatalogResponse (creates layout + git init if not a repo)
94+ * POST /api/catalog/clone CloneCatalogBody -> CatalogResponse (git clone into the catalog path, which must be absent or empty;
95+ * `path` also becomes settings.catalogPath; 400 with git's message on failure)
78 * GET /api/catalog/items/:type/:name -> CatalogItemDetail96 * GET /api/catalog/items/:type/:name -> CatalogItemDetail
79 * PUT /api/catalog/items/:type/:name SaveItemInput -> CatalogItem (create or update; rename via previousName)97 * PUT /api/catalog/items/:type/:name SaveItemInput -> CatalogItem (create or update; rename via previousName)
80 * DELETE /api/catalog/items/:type/:name -> 204 (409 if used by a scope config, unless ?force=1)98 * DELETE /api/catalog/items/:type/:name -> 204 (409 if used by a scope config, unless ?force=1)
@@ -130,6 +148,13 @@ export interface Settings {
130 autoSync: boolean148 autoSync: boolean
131 }149 }
132 150
151+export interface CloneCatalogBody {
152+ /** Git remote URL (SSH or HTTPS) of the catalog repository. */
153+ url: string
154+ /** Absolute target folder; defaults to the current catalog path. Saved as `settings.catalogPath` once the clone succeeded. */
155+ path?: string
156+}
157+
133 export interface CatalogResponse {158 export interface CatalogResponse {
134 snapshot: CatalogSnapshot159 snapshot: CatalogSnapshot
135 git: GitStatus160 git: GitStatus
modified packages/core/src/catalog/git.ts +59 -16
@@ -1,5 +1,5 @@
11 import { execFile } from 'node:child_process'
2-import { mkdir, readFile, rm } from 'node:fs/promises'
2+import { mkdir, readFile, readdir, rm } from 'node:fs/promises'
33 import path from 'node:path'
44 import type { GitStatus } from '../catalog-model.ts'
55 import type { CatalogGit, OpenCatalogGit } from '../contracts.ts'
@@ -80,11 +80,16 @@ class GitCatalog implements CatalogGit {
8080 }
8181
8282 private async identityArgs(): Promise<string[]> {
83- const email = await this.raw(['config', 'user.email'])
84- const name = await this.raw(['config', 'user.name'])
83+ // One process instead of two: every git spawn costs 50-150 ms on Windows.
84+ const r = await this.raw(['config', '--get-regexp', '^user\\.(name|email)$'])
85+ const keys = new Set<string>()
86+ for (const line of r.stdout.split(/\r?\n/)) {
87+ const i = line.indexOf(' ')
88+ if (i > 0 && line.slice(i + 1).trim()) keys.add(line.slice(0, i).toLowerCase())
89+ }
8590 const args: string[] = []
86- if (!name.stdout.trim()) args.push('-c', 'user.name=skilly')
87- if (!email.stdout.trim()) args.push('-c', 'user.email=skilly@localhost')
91+ if (!keys.has('user.name')) args.push('-c', 'user.name=skilly')
92+ if (!keys.has('user.email')) args.push('-c', 'user.email=skilly@localhost')
8893 return args
8994 }
9095
@@ -95,6 +100,18 @@ class GitCatalog implements CatalogGit {
95100 return names.includes('origin') ? 'origin' : names[0]
96101 }
97102
103+ /** URL of the remote used for sync (`origin`, else the first one), in a single git call. */
104+ private async remoteUrl(): Promise<string | undefined> {
105+ const r = await this.raw(['remote', '-v'])
106+ if (r.code !== 0) return undefined
107+ const urls = new Map<string, string>()
108+ for (const line of r.stdout.split(/\r?\n/)) {
109+ const m = /^(\S+)\s+(.+) \(fetch\)$/.exec(line.trim())
110+ if (m && !urls.has(m[1]!)) urls.set(m[1]!, m[2]!.trim())
111+ }
112+ return urls.get('origin') ?? urls.values().next().value
113+ }
114+
98115 private async hasCommits(): Promise<boolean> {
99116 return (await this.raw(['rev-parse', '--verify', '-q', 'HEAD'])).code === 0
100117 }
@@ -102,16 +119,14 @@ class GitCatalog implements CatalogGit {
102119 async status(): Promise<GitStatus> {
103120 const st: GitStatus = { isRepo: false, ahead: 0, behind: 0, dirty: [], conflicts: [] }
104121 if (!(await this.isRepo())) return st
105- const r = await this.raw(['status', '--porcelain=v2', '--branch', '--untracked-files=all', '-z'])
122+ // --no-optional-locks: a read-only status must never take .git/index.lock, or it would make a concurrent commit
123+ // (auto-sync) fail with "Unable to create index.lock" while the UI polls the status.
124+ const r = await this.raw(['--no-optional-locks', 'status', '--porcelain=v2', '--branch', '--untracked-files=all', '-z'])
106125 if (r.code !== 0) throw failure(['status'], r)
107126 st.isRepo = true
108127 parseStatus(r.stdout, st)
109- const remote = await this.remoteName()
110- if (remote) {
111- const url = await this.raw(['remote', 'get-url', remote])
112- if (url.code === 0 && url.stdout.trim()) st.remote = url.stdout.trim()
113- }
114- const log = await this.raw(['log', '-1', '--format=%H%x1f%s%x1f%aI'])
128+ const [remoteUrl, log] = await Promise.all([this.remoteUrl(), this.raw(['log', '-1', '--format=%H%x1f%s%x1f%aI'])])
129+ if (remoteUrl) st.remote = remoteUrl
115130 if (log.code === 0 && log.stdout.trim()) {
116131 const [hash, message, date] = log.stdout.trim().split('\x1f')
117132 if (hash) st.lastCommit = { hash, message: message ?? '', date: date ?? '' }
@@ -125,6 +140,31 @@ class GitCatalog implements CatalogGit {
125140 await this.git(['init', '--initial-branch=main'])
126141 }
127142
143+ async clone(url: string): Promise<GitStatus> {
144+ const target = url.trim()
145+ if (!target) throw new Error('Remote URL is empty')
146+ if (target.startsWith('-')) throw new Error('Invalid remote URL')
147+ if (await this.isRepo()) throw new Error(`Already a git repository: ${this.root}`)
148+ let existed = true
149+ try {
150+ if ((await readdir(this.root)).length > 0) throw new Error(`The catalog folder is not empty: ${this.root}`)
151+ } catch (e) {
152+ if ((e as NodeJS.ErrnoException).code !== 'ENOENT') throw e
153+ existed = false
154+ }
155+ await mkdir(this.root, { recursive: true })
156+ const args = ['clone', '--quiet', '--', target, '.']
157+ const r = await this.raw(args)
158+ if (r.code !== 0) {
159+ // git removes what it created, but leave the folder exactly as we found it.
160+ if (existed) {
161+ for (const name of await readdir(this.root)) await rm(path.join(this.root, name), { recursive: true, force: true })
162+ } else await rm(this.root, { recursive: true, force: true })
163+ throw failure(args, r)
164+ }
165+ return this.status()
166+ }
167+
128168 async setRemote(url: string): Promise<void> {
129169 await this.requireRepo()
130170 if (!url.trim()) throw new Error('Remote URL is empty')
@@ -136,10 +176,13 @@ class GitCatalog implements CatalogGit {
136176 async commitAll(message: string): Promise<string | undefined> {
137177 await this.requireRepo()
138178 await this.git(['add', '-A'])
139- const s = await this.git(['status', '--porcelain'])
140- const merging = (await this.raw(['rev-parse', '-q', '--verify', 'MERGE_HEAD'])).code === 0
141- if (!s.trim() && !merging) return undefined
142- await this.git([...(await this.identityArgs()), 'commit', '-m', message || 'skilly sync'])
179+ const [s, merge, identity] = await Promise.all([
180+ this.git(['status', '--porcelain']),
181+ this.raw(['rev-parse', '-q', '--verify', 'MERGE_HEAD']),
182+ this.identityArgs(),
183+ ])
184+ if (!s.trim() && merge.code !== 0) return undefined
185+ await this.git([...identity, 'commit', '-m', message || 'skilly sync'])
143186 return (await this.currentCommit()) ?? undefined
144187 }
145188
@@ -1,5 +1,5 @@
1 import { execFile } from 'node:child_process'1 import { execFile } from 'node:child_process'
2-import { mkdir, readFile, rm } from 'node:fs/promises'2+import { mkdir, readFile, readdir, rm } from 'node:fs/promises'
3 import path from 'node:path'3 import path from 'node:path'
4 import type { GitStatus } from '../catalog-model.ts'4 import type { GitStatus } from '../catalog-model.ts'
5 import type { CatalogGit, OpenCatalogGit } from '../contracts.ts'5 import type { CatalogGit, OpenCatalogGit } from '../contracts.ts'
@@ -80,11 +80,16 @@ class GitCatalog implements CatalogGit {
80 }80 }
81 81
82 private async identityArgs(): Promise<string[]> {82 private async identityArgs(): Promise<string[]> {
83- const email = await this.raw(['config', 'user.email'])83+ // One process instead of two: every git spawn costs 50-150 ms on Windows.
84- const name = await this.raw(['config', 'user.name'])84+ const r = await this.raw(['config', '--get-regexp', '^user\\.(name|email)$'])
85+ const keys = new Set<string>()
86+ for (const line of r.stdout.split(/\r?\n/)) {
87+ const i = line.indexOf(' ')
88+ if (i > 0 && line.slice(i + 1).trim()) keys.add(line.slice(0, i).toLowerCase())
89+ }
85 const args: string[] = []90 const args: string[] = []
86- if (!name.stdout.trim()) args.push('-c', 'user.name=skilly')91+ if (!keys.has('user.name')) args.push('-c', 'user.name=skilly')
87- if (!email.stdout.trim()) args.push('-c', 'user.email=skilly@localhost')92+ if (!keys.has('user.email')) args.push('-c', 'user.email=skilly@localhost')
88 return args93 return args
89 }94 }
90 95
@@ -95,6 +100,18 @@ class GitCatalog implements CatalogGit {
95 return names.includes('origin') ? 'origin' : names[0]100 return names.includes('origin') ? 'origin' : names[0]
96 }101 }
97 102
103+ /** URL of the remote used for sync (`origin`, else the first one), in a single git call. */
104+ private async remoteUrl(): Promise<string | undefined> {
105+ const r = await this.raw(['remote', '-v'])
106+ if (r.code !== 0) return undefined
107+ const urls = new Map<string, string>()
108+ for (const line of r.stdout.split(/\r?\n/)) {
109+ const m = /^(\S+)\s+(.+) \(fetch\)$/.exec(line.trim())
110+ if (m && !urls.has(m[1]!)) urls.set(m[1]!, m[2]!.trim())
111+ }
112+ return urls.get('origin') ?? urls.values().next().value
113+ }
114+
98 private async hasCommits(): Promise<boolean> {115 private async hasCommits(): Promise<boolean> {
99 return (await this.raw(['rev-parse', '--verify', '-q', 'HEAD'])).code === 0116 return (await this.raw(['rev-parse', '--verify', '-q', 'HEAD'])).code === 0
100 }117 }
@@ -102,16 +119,14 @@ class GitCatalog implements CatalogGit {
102 async status(): Promise<GitStatus> {119 async status(): Promise<GitStatus> {
103 const st: GitStatus = { isRepo: false, ahead: 0, behind: 0, dirty: [], conflicts: [] }120 const st: GitStatus = { isRepo: false, ahead: 0, behind: 0, dirty: [], conflicts: [] }
104 if (!(await this.isRepo())) return st121 if (!(await this.isRepo())) return st
105- const r = await this.raw(['status', '--porcelain=v2', '--branch', '--untracked-files=all', '-z'])122+ // --no-optional-locks: a read-only status must never take .git/index.lock, or it would make a concurrent commit
123+ // (auto-sync) fail with "Unable to create index.lock" while the UI polls the status.
124+ const r = await this.raw(['--no-optional-locks', 'status', '--porcelain=v2', '--branch', '--untracked-files=all', '-z'])
106 if (r.code !== 0) throw failure(['status'], r)125 if (r.code !== 0) throw failure(['status'], r)
107 st.isRepo = true126 st.isRepo = true
108 parseStatus(r.stdout, st)127 parseStatus(r.stdout, st)
109- const remote = await this.remoteName()128+ const [remoteUrl, log] = await Promise.all([this.remoteUrl(), this.raw(['log', '-1', '--format=%H%x1f%s%x1f%aI'])])
110- if (remote) {129+ if (remoteUrl) st.remote = remoteUrl
111- const url = await this.raw(['remote', 'get-url', remote])
112- if (url.code === 0 && url.stdout.trim()) st.remote = url.stdout.trim()
113- }
114- const log = await this.raw(['log', '-1', '--format=%H%x1f%s%x1f%aI'])
115 if (log.code === 0 && log.stdout.trim()) {130 if (log.code === 0 && log.stdout.trim()) {
116 const [hash, message, date] = log.stdout.trim().split('\x1f')131 const [hash, message, date] = log.stdout.trim().split('\x1f')
117 if (hash) st.lastCommit = { hash, message: message ?? '', date: date ?? '' }132 if (hash) st.lastCommit = { hash, message: message ?? '', date: date ?? '' }
@@ -125,6 +140,31 @@ class GitCatalog implements CatalogGit {
125 await this.git(['init', '--initial-branch=main'])140 await this.git(['init', '--initial-branch=main'])
126 }141 }
127 142
143+ async clone(url: string): Promise<GitStatus> {
144+ const target = url.trim()
145+ if (!target) throw new Error('Remote URL is empty')
146+ if (target.startsWith('-')) throw new Error('Invalid remote URL')
147+ if (await this.isRepo()) throw new Error(`Already a git repository: ${this.root}`)
148+ let existed = true
149+ try {
150+ if ((await readdir(this.root)).length > 0) throw new Error(`The catalog folder is not empty: ${this.root}`)
151+ } catch (e) {
152+ if ((e as NodeJS.ErrnoException).code !== 'ENOENT') throw e
153+ existed = false
154+ }
155+ await mkdir(this.root, { recursive: true })
156+ const args = ['clone', '--quiet', '--', target, '.']
157+ const r = await this.raw(args)
158+ if (r.code !== 0) {
159+ // git removes what it created, but leave the folder exactly as we found it.
160+ if (existed) {
161+ for (const name of await readdir(this.root)) await rm(path.join(this.root, name), { recursive: true, force: true })
162+ } else await rm(this.root, { recursive: true, force: true })
163+ throw failure(args, r)
164+ }
165+ return this.status()
166+ }
167+
128 async setRemote(url: string): Promise<void> {168 async setRemote(url: string): Promise<void> {
129 await this.requireRepo()169 await this.requireRepo()
130 if (!url.trim()) throw new Error('Remote URL is empty')170 if (!url.trim()) throw new Error('Remote URL is empty')
@@ -136,10 +176,13 @@ class GitCatalog implements CatalogGit {
136 async commitAll(message: string): Promise<string | undefined> {176 async commitAll(message: string): Promise<string | undefined> {
137 await this.requireRepo()177 await this.requireRepo()
138 await this.git(['add', '-A'])178 await this.git(['add', '-A'])
139- const s = await this.git(['status', '--porcelain'])179+ const [s, merge, identity] = await Promise.all([
140- const merging = (await this.raw(['rev-parse', '-q', '--verify', 'MERGE_HEAD'])).code === 0180+ this.git(['status', '--porcelain']),
141- if (!s.trim() && !merging) return undefined181+ this.raw(['rev-parse', '-q', '--verify', 'MERGE_HEAD']),
142- await this.git([...(await this.identityArgs()), 'commit', '-m', message || 'skilly sync'])182+ this.identityArgs(),
183+ ])
184+ if (!s.trim() && merge.code !== 0) return undefined
185+ await this.git([...identity, 'commit', '-m', message || 'skilly sync'])
143 return (await this.currentCommit()) ?? undefined186 return (await this.currentCommit()) ?? undefined
144 }187 }
145 188
modified packages/core/src/contracts.ts +5 -0
@@ -57,6 +57,11 @@ export type OpenCatalog = (root: string) => CatalogStore
5757 export interface CatalogGit {
5858 status(): Promise<GitStatus>
5959 init(): Promise<void>
60+ /**
61+ * Clones `url` into the catalog folder, which must be absent or empty (no `git init` first). Returns the status.
62+ * Added after the first release: callers that only know the older contract simply never call it.
63+ */
64+ clone(url: string): Promise<GitStatus>
6065 setRemote(url: string): Promise<void>
6166 /** Stages everything and commits; no-op when clean. Returns the commit hash, if any. */
6267 commitAll(message: string): Promise<string | undefined>
@@ -57,6 +57,11 @@ export type OpenCatalog = (root: string) => CatalogStore
57 export interface CatalogGit {57 export interface CatalogGit {
58 status(): Promise<GitStatus>58 status(): Promise<GitStatus>
59 init(): Promise<void>59 init(): Promise<void>
60+ /**
61+ * Clones `url` into the catalog folder, which must be absent or empty (no `git init` first). Returns the status.
62+ * Added after the first release: callers that only know the older contract simply never call it.
63+ */
64+ clone(url: string): Promise<GitStatus>
60 setRemote(url: string): Promise<void>65 setRemote(url: string): Promise<void>
61 /** Stages everything and commits; no-op when clean. Returns the commit hash, if any. */66 /** Stages everything and commits; no-op when clean. Returns the commit hash, if any. */
62 commitAll(message: string): Promise<string | undefined>67 commitAll(message: string): Promise<string | undefined>
modified packages/core/test/catalog-git.test.ts +30 -0
@@ -146,4 +146,34 @@ describe('catalog git', { timeout: 60_000 }, () => {
146146 await expect(g.pull()).rejects.toThrow(/does-not-exist|repository/i)
147147 await g.setRemote(remote)
148148 })
149+
150+ it('clones a remote into an absent or empty folder, keeping line endings', async () => {
151+ const absent = path.join(tmp, 'clone-absent', 'catalog')
152+ const st = await openCatalogGit(absent).clone(remote)
153+ expect(st.isRepo).toBe(true)
154+ expect(st.remote).toBe(remote)
155+ expect(st.branch).toBe('main')
156+ expect(await readFile(path.join(absent, 'skills', 'x', 'SKILL.md'), 'utf8')).toBe('one\n')
157+
158+ const empty = path.join(tmp, 'clone-empty')
159+ await mkdir(empty)
160+ expect((await openCatalogGit(empty).clone(remote)).lastCommit).toBeDefined()
161+ })
162+
163+ it('refuses to clone into a non-empty folder or a repo, and cleans up after a failure', async () => {
164+ const full = path.join(tmp, 'clone-full')
165+ await mkdir(full)
166+ await writeFile(path.join(full, 'keep.txt'), 'x')
167+ await expect(openCatalogGit(full).clone(remote)).rejects.toThrow(/not empty/)
168+ await expect(openCatalogGit(a).clone(remote)).rejects.toThrow(/Already a git repository/)
169+ await expect(openCatalogGit(path.join(tmp, 'clone-opt')).clone('--upload-pack=x')).rejects.toThrow(/Invalid/)
170+
171+ const failed = path.join(tmp, 'clone-failed')
172+ await expect(openCatalogGit(failed).clone(path.join(tmp, 'nope.git'))).rejects.toThrow()
173+ await expect(readFile(failed)).rejects.toThrow() // folder removed again
174+ const failedEmpty = path.join(tmp, 'clone-failed-empty')
175+ await mkdir(failedEmpty)
176+ await expect(openCatalogGit(failedEmpty).clone(path.join(tmp, 'nope.git'))).rejects.toThrow()
177+ await expect(readFile(path.join(failedEmpty, '.git', 'HEAD'))).rejects.toThrow()
178+ })
149179 })
@@ -146,4 +146,34 @@ describe('catalog git', { timeout: 60_000 }, () => {
146 await expect(g.pull()).rejects.toThrow(/does-not-exist|repository/i)146 await expect(g.pull()).rejects.toThrow(/does-not-exist|repository/i)
147 await g.setRemote(remote)147 await g.setRemote(remote)
148 })148 })
149+
150+ it('clones a remote into an absent or empty folder, keeping line endings', async () => {
151+ const absent = path.join(tmp, 'clone-absent', 'catalog')
152+ const st = await openCatalogGit(absent).clone(remote)
153+ expect(st.isRepo).toBe(true)
154+ expect(st.remote).toBe(remote)
155+ expect(st.branch).toBe('main')
156+ expect(await readFile(path.join(absent, 'skills', 'x', 'SKILL.md'), 'utf8')).toBe('one\n')
157+
158+ const empty = path.join(tmp, 'clone-empty')
159+ await mkdir(empty)
160+ expect((await openCatalogGit(empty).clone(remote)).lastCommit).toBeDefined()
161+ })
162+
163+ it('refuses to clone into a non-empty folder or a repo, and cleans up after a failure', async () => {
164+ const full = path.join(tmp, 'clone-full')
165+ await mkdir(full)
166+ await writeFile(path.join(full, 'keep.txt'), 'x')
167+ await expect(openCatalogGit(full).clone(remote)).rejects.toThrow(/not empty/)
168+ await expect(openCatalogGit(a).clone(remote)).rejects.toThrow(/Already a git repository/)
169+ await expect(openCatalogGit(path.join(tmp, 'clone-opt')).clone('--upload-pack=x')).rejects.toThrow(/Invalid/)
170+
171+ const failed = path.join(tmp, 'clone-failed')
172+ await expect(openCatalogGit(failed).clone(path.join(tmp, 'nope.git'))).rejects.toThrow()
173+ await expect(readFile(failed)).rejects.toThrow() // folder removed again
174+ const failedEmpty = path.join(tmp, 'clone-failed-empty')
175+ await mkdir(failedEmpty)
176+ await expect(openCatalogGit(failedEmpty).clone(path.join(tmp, 'nope.git'))).rejects.toThrow()
177+ await expect(readFile(path.join(failedEmpty, '.git', 'HEAD'))).rejects.toThrow()
178+ })
149 })179 })
modified packages/server/src/catalog-routes.ts +20 -0
@@ -164,6 +164,26 @@ export function registerCatalogRoutes(app: Hono, ctx: AppContext): void {
164164 return c.json(await catalogResponse())
165165 })
166166
167+ app.post('/api/catalog/clone', async (c) => {
168+ const body = await readJsonBody(c)
169+ const url = requireUrlLike(body.url, 'url')
170+ const current = await ctx.settings()
171+ let target = current.catalogPath
172+ if (body.path !== undefined) {
173+ if (typeof body.path !== 'string' || !path.isAbsolute(body.path)) {
174+ throw new HttpError(400, '"path" must be an absolute path')
175+ }
176+ target = path.resolve(body.path)
177+ }
178+ await ctx.openCatalogGit(target).clone(url)
179+ if (target !== current.catalogPath) await ctx.store.setSettings({ ...current, catalogPath: target })
180+ // A fresh, empty remote has no catalog layout yet: create it so the UI can start working right away.
181+ const catalog = await ctx.catalog()
182+ if (!(await catalog.exists())) await catalog.init()
183+ ctx.catalogChanged()
184+ return c.json(await catalogResponse())
185+ })
186+
167187 app.get('/api/catalog/items/:type/:name', async (c) => {
168188 const ref: CatalogRef = { type: parseType(c.req.param('type')), name: c.req.param('name') }
169189 const detail = await (await ctx.catalog()).getItem(ref)
@@ -164,6 +164,26 @@ export function registerCatalogRoutes(app: Hono, ctx: AppContext): void {
164 return c.json(await catalogResponse())164 return c.json(await catalogResponse())
165 })165 })
166 166
167+ app.post('/api/catalog/clone', async (c) => {
168+ const body = await readJsonBody(c)
169+ const url = requireUrlLike(body.url, 'url')
170+ const current = await ctx.settings()
171+ let target = current.catalogPath
172+ if (body.path !== undefined) {
173+ if (typeof body.path !== 'string' || !path.isAbsolute(body.path)) {
174+ throw new HttpError(400, '"path" must be an absolute path')
175+ }
176+ target = path.resolve(body.path)
177+ }
178+ await ctx.openCatalogGit(target).clone(url)
179+ if (target !== current.catalogPath) await ctx.store.setSettings({ ...current, catalogPath: target })
180+ // A fresh, empty remote has no catalog layout yet: create it so the UI can start working right away.
181+ const catalog = await ctx.catalog()
182+ if (!(await catalog.exists())) await catalog.init()
183+ ctx.catalogChanged()
184+ return c.json(await catalogResponse())
185+ })
186+
167 app.get('/api/catalog/items/:type/:name', async (c) => {187 app.get('/api/catalog/items/:type/:name', async (c) => {
168 const ref: CatalogRef = { type: parseType(c.req.param('type')), name: c.req.param('name') }188 const ref: CatalogRef = { type: parseType(c.req.param('type')), name: c.req.param('name') }
169 const detail = await (await ctx.catalog()).getItem(ref)189 const detail = await (await ctx.catalog()).getItem(ref)
modified packages/server/src/fslist.ts +75 -1
@@ -2,7 +2,7 @@ import { existsSync } from 'node:fs'
22 import { readdir, stat } from 'node:fs/promises'
33 import os from 'node:os'
44 import path from 'node:path'
5-import type { FsEntry, FsListing } from '@skilly/core'
5+import type { DiscoveredProject, FsDiscovery, FsEntry, FsListing } from '@skilly/core'
66
77 const EXCLUDED = new Set(['node_modules', '$recycle.bin', 'system volume information'])
88 const AGENT_MARKERS = ['.claude', '.agents', '.codex', 'AGENTS.md', 'CLAUDE.md', '.mcp.json']
@@ -57,3 +57,77 @@ export async function listDirectory(dir: string): Promise<FsListing> {
5757 const parentDir = path.dirname(abs)
5858 return { path: abs, parent: parentDir === abs ? null : parentDir, entries }
5959 }
60+
61+/** Folder names never descended into while discovering projects (build output, system and package folders). */
62+const DISCOVER_SKIP = new Set([
63+ ...EXCLUDED,
64+ 'windows',
65+ 'program files',
66+ 'program files (x86)',
67+ 'programdata',
68+ 'appdata',
69+ 'library',
70+ 'system',
71+ 'proc',
72+ 'sys',
73+ 'dist',
74+ 'build',
75+ 'target',
76+ 'vendor',
77+])
78+
79+export interface DiscoverOptions {
80+ /** Levels below the root to inspect (1 = direct children). Clamped to 1..4. */
81+ depth?: number
82+ /** Stops after this many projects (default 100). */
83+ maxResults?: number
84+ /** Stops after this delay (default 4000 ms). */
85+ timeoutMs?: number
86+}
87+
88+/**
89+ * Breadth-first search of folders with agent configuration under `root`. Hidden folders, symlinks and system folders
90+ * are skipped, a folder with agent config is not searched further, and the search is capped in results and time.
91+ * The root itself is never reported.
92+ */
93+export async function discoverProjects(root: string, options: DiscoverOptions = {}): Promise<FsDiscovery> {
94+ const abs = path.resolve(root)
95+ const st = await stat(abs)
96+ if (!st.isDirectory()) throw Object.assign(new Error('Not a directory'), { code: 'ENOTDIR' })
97+ const depth = Math.min(4, Math.max(1, Math.trunc(options.depth ?? 2) || 2))
98+ const maxResults = options.maxResults ?? 100
99+ const deadline = Date.now() + (options.timeoutMs ?? 4000)
100+ const entries: DiscoveredProject[] = []
101+ let truncated = false
102+ let level = [abs]
103+ for (let d = 1; d <= depth && level.length > 0 && !truncated; d++) {
104+ const next: string[] = []
105+ for (const dir of level) {
106+ if (Date.now() > deadline || entries.length >= maxResults) {
107+ truncated = true
108+ break
109+ }
110+ let dirents
111+ try {
112+ dirents = await readdir(dir, { withFileTypes: true })
113+ } catch {
114+ continue // unreadable
115+ }
116+ dirents.sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase()))
117+ for (const e of dirents) {
118+ if (!e.isDirectory() || e.name.startsWith('.') || DISCOVER_SKIP.has(e.name.toLowerCase())) continue
119+ const full = path.join(dir, e.name)
120+ const markers = AGENT_MARKERS.filter((m) => existsSync(path.join(full, m)))
121+ if (markers.length > 0) {
122+ if (entries.length >= maxResults) {
123+ truncated = true
124+ break
125+ }
126+ entries.push({ name: e.name, path: full, hasAgentConfig: true, markers })
127+ } else next.push(full)
128+ }
129+ }
130+ level = next
131+ }
132+ return { root: abs, depth, entries, truncated }
133+}
@@ -2,7 +2,7 @@ import { existsSync } from 'node:fs'
2 import { readdir, stat } from 'node:fs/promises'2 import { readdir, stat } from 'node:fs/promises'
3 import os from 'node:os'3 import os from 'node:os'
4 import path from 'node:path'4 import path from 'node:path'
5-import type { FsEntry, FsListing } from '@skilly/core'5+import type { DiscoveredProject, FsDiscovery, FsEntry, FsListing } from '@skilly/core'
6 6
7 const EXCLUDED = new Set(['node_modules', '$recycle.bin', 'system volume information'])7 const EXCLUDED = new Set(['node_modules', '$recycle.bin', 'system volume information'])
8 const AGENT_MARKERS = ['.claude', '.agents', '.codex', 'AGENTS.md', 'CLAUDE.md', '.mcp.json']8 const AGENT_MARKERS = ['.claude', '.agents', '.codex', 'AGENTS.md', 'CLAUDE.md', '.mcp.json']
@@ -57,3 +57,77 @@ export async function listDirectory(dir: string): Promise<FsListing> {
57 const parentDir = path.dirname(abs)57 const parentDir = path.dirname(abs)
58 return { path: abs, parent: parentDir === abs ? null : parentDir, entries }58 return { path: abs, parent: parentDir === abs ? null : parentDir, entries }
59 }59 }
60+
61+/** Folder names never descended into while discovering projects (build output, system and package folders). */
62+const DISCOVER_SKIP = new Set([
63+ ...EXCLUDED,
64+ 'windows',
65+ 'program files',
66+ 'program files (x86)',
67+ 'programdata',
68+ 'appdata',
69+ 'library',
70+ 'system',
71+ 'proc',
72+ 'sys',
73+ 'dist',
74+ 'build',
75+ 'target',
76+ 'vendor',
77+])
78+
79+export interface DiscoverOptions {
80+ /** Levels below the root to inspect (1 = direct children). Clamped to 1..4. */
81+ depth?: number
82+ /** Stops after this many projects (default 100). */
83+ maxResults?: number
84+ /** Stops after this delay (default 4000 ms). */
85+ timeoutMs?: number
86+}
87+
88+/**
89+ * Breadth-first search of folders with agent configuration under `root`. Hidden folders, symlinks and system folders
90+ * are skipped, a folder with agent config is not searched further, and the search is capped in results and time.
91+ * The root itself is never reported.
92+ */
93+export async function discoverProjects(root: string, options: DiscoverOptions = {}): Promise<FsDiscovery> {
94+ const abs = path.resolve(root)
95+ const st = await stat(abs)
96+ if (!st.isDirectory()) throw Object.assign(new Error('Not a directory'), { code: 'ENOTDIR' })
97+ const depth = Math.min(4, Math.max(1, Math.trunc(options.depth ?? 2) || 2))
98+ const maxResults = options.maxResults ?? 100
99+ const deadline = Date.now() + (options.timeoutMs ?? 4000)
100+ const entries: DiscoveredProject[] = []
101+ let truncated = false
102+ let level = [abs]
103+ for (let d = 1; d <= depth && level.length > 0 && !truncated; d++) {
104+ const next: string[] = []
105+ for (const dir of level) {
106+ if (Date.now() > deadline || entries.length >= maxResults) {
107+ truncated = true
108+ break
109+ }
110+ let dirents
111+ try {
112+ dirents = await readdir(dir, { withFileTypes: true })
113+ } catch {
114+ continue // unreadable
115+ }
116+ dirents.sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase()))
117+ for (const e of dirents) {
118+ if (!e.isDirectory() || e.name.startsWith('.') || DISCOVER_SKIP.has(e.name.toLowerCase())) continue
119+ const full = path.join(dir, e.name)
120+ const markers = AGENT_MARKERS.filter((m) => existsSync(path.join(full, m)))
121+ if (markers.length > 0) {
122+ if (entries.length >= maxResults) {
123+ truncated = true
124+ break
125+ }
126+ entries.push({ name: e.name, path: full, hasAgentConfig: true, markers })
127+ } else next.push(full)
128+ }
129+ }
130+ level = next
131+ }
132+ return { root: abs, depth, entries, truncated }
133+}
modified packages/server/src/index.ts +17 -1
@@ -31,7 +31,7 @@ import { HttpError, guardGitConflicts, isDirectory, makeScopeRef, type AppContex
3131 import { registerMarketplaceRoutes } from './marketplace-routes.ts'
3232 import { registerScopeRoutes } from './scope-routes.ts'
3333 import { FileAccessError, isInside, readAllowedFile } from './files.ts'
34-import { listDirectory, listRoots } from './fslist.ts'
34+import { discoverProjects, listDirectory, listRoots } from './fslist.ts'
3535 import { ProjectStore, type StoredProject } from './store.ts'
3636
3737 export const SERVER_VERSION = '0.0.0'
@@ -313,6 +313,22 @@ export function createAppHandle(options: AppOptions): AppHandle {
313313 }
314314 })
315315
316+ app.get('/api/fs/discover', async (c) => {
317+ const root = c.req.query('root')
318+ if (!root) return c.json(err('Missing "root"'), 400)
319+ if (!path.isAbsolute(root)) return c.json(err('Path must be absolute'), 400)
320+ const depthRaw = c.req.query('depth')
321+ const depth = depthRaw === undefined ? 2 : Number(depthRaw)
322+ if (!Number.isInteger(depth) || depth < 1 || depth > 4) return c.json(err('"depth" must be an integer between 1 and 4'), 400)
323+ try {
324+ return c.json(await discoverProjects(root, { depth }))
325+ } catch (e) {
326+ const code = (e as NodeJS.ErrnoException).code
327+ if (code === 'ENOENT' || code === 'ENOTDIR') return c.json(err('Not a directory'), 404)
328+ return c.json(err('Cannot read directory'), 403)
329+ }
330+ })
331+
316332 app.get('/api/files', async (c) => {
317333 const p = c.req.query('path')
318334 if (!p) return c.json(err('Missing "path"'), 400)
@@ -31,7 +31,7 @@ import { HttpError, guardGitConflicts, isDirectory, makeScopeRef, type AppContex
31 import { registerMarketplaceRoutes } from './marketplace-routes.ts'31 import { registerMarketplaceRoutes } from './marketplace-routes.ts'
32 import { registerScopeRoutes } from './scope-routes.ts'32 import { registerScopeRoutes } from './scope-routes.ts'
33 import { FileAccessError, isInside, readAllowedFile } from './files.ts'33 import { FileAccessError, isInside, readAllowedFile } from './files.ts'
34-import { listDirectory, listRoots } from './fslist.ts'34+import { discoverProjects, listDirectory, listRoots } from './fslist.ts'
35 import { ProjectStore, type StoredProject } from './store.ts'35 import { ProjectStore, type StoredProject } from './store.ts'
36 36
37 export const SERVER_VERSION = '0.0.0'37 export const SERVER_VERSION = '0.0.0'
@@ -313,6 +313,22 @@ export function createAppHandle(options: AppOptions): AppHandle {
313 }313 }
314 })314 })
315 315
316+ app.get('/api/fs/discover', async (c) => {
317+ const root = c.req.query('root')
318+ if (!root) return c.json(err('Missing "root"'), 400)
319+ if (!path.isAbsolute(root)) return c.json(err('Path must be absolute'), 400)
320+ const depthRaw = c.req.query('depth')
321+ const depth = depthRaw === undefined ? 2 : Number(depthRaw)
322+ if (!Number.isInteger(depth) || depth < 1 || depth > 4) return c.json(err('"depth" must be an integer between 1 and 4'), 400)
323+ try {
324+ return c.json(await discoverProjects(root, { depth }))
325+ } catch (e) {
326+ const code = (e as NodeJS.ErrnoException).code
327+ if (code === 'ENOENT' || code === 'ENOTDIR') return c.json(err('Not a directory'), 404)
328+ return c.json(err('Cannot read directory'), 403)
329+ }
330+ })
331+
316 app.get('/api/files', async (c) => {332 app.get('/api/files', async (c) => {
317 const p = c.req.query('path')333 const p = c.req.query('path')
318 if (!p) return c.json(err('Missing "path"'), 400)334 if (!p) return c.json(err('Missing "path"'), 400)
modified packages/server/test/catalog.test.ts +4 -3
@@ -132,7 +132,8 @@ describe('catalog', () => {
132132 })
133133 })
134134
135-describe('catalog git', () => {
135+// Each request spawns several git processes (50-150 ms each on Windows): allow for a loaded machine.
136+describe('catalog git', { timeout: 30_000 }, () => {
136137 let remote: string
137138
138139 beforeEach(async () => {
@@ -179,7 +180,7 @@ describe('catalog git', () => {
179180 it('auto-syncs after writes when enabled (push with a remote, commit without)', async () => {
180181 await env.send('PUT', '/api/settings', { autoSync: true })
181182 await env.send('PUT', '/api/catalog/items/skill/auto1', { content: skillMd('auto1') })
182- await waitFor(() => git(env.catalogDir, 'status', '--porcelain').trim() === '')
183+ await waitFor(() => git(env.catalogDir, '--no-optional-locks', 'status', '--porcelain').trim() === '')
183184 expect(git(env.catalogDir, 'log', '--oneline')).toContain('skilly')
184185
185186 await env.send('POST', '/api/catalog/git/remote', { url: remote })
@@ -191,7 +192,7 @@ describe('catalog git', () => {
191192 it('does not commit when autoSync is off', async () => {
192193 await env.send('PUT', '/api/catalog/items/skill/manual', { content: skillMd('manual') })
193194 await new Promise((r) => setTimeout(r, 300))
194- expect(git(env.catalogDir, 'status', '--porcelain').trim()).not.toBe('')
195+ expect(git(env.catalogDir, '--no-optional-locks', 'status', '--porcelain').trim()).not.toBe('')
195196 })
196197 })
197198
@@ -132,7 +132,8 @@ describe('catalog', () => {
132 })132 })
133 })133 })
134 134
135-describe('catalog git', () => {135+// Each request spawns several git processes (50-150 ms each on Windows): allow for a loaded machine.
136+describe('catalog git', { timeout: 30_000 }, () => {
136 let remote: string137 let remote: string
137 138
138 beforeEach(async () => {139 beforeEach(async () => {
@@ -179,7 +180,7 @@ describe('catalog git', () => {
179 it('auto-syncs after writes when enabled (push with a remote, commit without)', async () => {180 it('auto-syncs after writes when enabled (push with a remote, commit without)', async () => {
180 await env.send('PUT', '/api/settings', { autoSync: true })181 await env.send('PUT', '/api/settings', { autoSync: true })
181 await env.send('PUT', '/api/catalog/items/skill/auto1', { content: skillMd('auto1') })182 await env.send('PUT', '/api/catalog/items/skill/auto1', { content: skillMd('auto1') })
182- await waitFor(() => git(env.catalogDir, 'status', '--porcelain').trim() === '')183+ await waitFor(() => git(env.catalogDir, '--no-optional-locks', 'status', '--porcelain').trim() === '')
183 expect(git(env.catalogDir, 'log', '--oneline')).toContain('skilly')184 expect(git(env.catalogDir, 'log', '--oneline')).toContain('skilly')
184 185
185 await env.send('POST', '/api/catalog/git/remote', { url: remote })186 await env.send('POST', '/api/catalog/git/remote', { url: remote })
@@ -191,7 +192,7 @@ describe('catalog git', () => {
191 it('does not commit when autoSync is off', async () => {192 it('does not commit when autoSync is off', async () => {
192 await env.send('PUT', '/api/catalog/items/skill/manual', { content: skillMd('manual') })193 await env.send('PUT', '/api/catalog/items/skill/manual', { content: skillMd('manual') })
193 await new Promise((r) => setTimeout(r, 300))194 await new Promise((r) => setTimeout(r, 300))
194- expect(git(env.catalogDir, 'status', '--porcelain').trim()).not.toBe('')195+ expect(git(env.catalogDir, '--no-optional-locks', 'status', '--porcelain').trim()).not.toBe('')
195 })196 })
196 })197 })
197 198
added packages/server/test/onboarding.test.ts +122 -0
new file mode 100644
@@ -0,0 +1,122 @@
1+import { execFileSync } from 'node:child_process'
2+import { mkdir, readFile } from 'node:fs/promises'
3+import path from 'node:path'
4+import type { CatalogResponse, FsDiscovery, Settings } from '@skilly/core'
5+import { afterEach, beforeEach, describe, expect, it } from 'vitest'
6+import { fakeEngine, git, makeEnv, put, skillMd, type Env } from './helpers.ts'
7+
8+let env: Env
9+
10+beforeEach(async () => {
11+ env = await makeEnv({ syncEngine: fakeEngine() })
12+})
13+
14+afterEach(() => env.cleanup())
15+
16+describe('POST /api/catalog/clone', { timeout: 60_000 }, () => {
17+ let remote: string
18+
19+ /** A bare remote holding one skill, pushed from a scratch clone. */
20+ async function populatedRemote(): Promise<string> {
21+ const bare = path.join(env.tmp, 'remote.git')
22+ await mkdir(bare, { recursive: true })
23+ execFileSync('git', ['init', '--bare', '-q', '-b', 'main', bare], { stdio: 'ignore' })
24+ const seed = path.join(env.tmp, 'seed')
25+ await mkdir(seed, { recursive: true })
26+ git(seed, 'init', '-q', '-b', 'main')
27+ await put(path.join(seed, 'skills', 'alpha', 'SKILL.md'), skillMd('alpha'))
28+ git(seed, 'add', '-A')
29+ git(seed, 'commit', '-q', '-m', 'seed')
30+ git(seed, 'push', '-q', bare, 'main')
31+ return bare
32+ }
33+
34+ beforeEach(async () => {
35+ remote = await populatedRemote()
36+ })
37+
38+ it('clones into the current catalog path and returns the catalog', async () => {
39+ const res = await env.send('POST', '/api/catalog/clone', { url: remote })
40+ expect(res.status).toBe(200)
41+ const cat = (await res.json()) as CatalogResponse
42+ expect(cat.git.isRepo).toBe(true)
43+ expect(cat.git.remote).toBeTruthy()
44+ expect(cat.git.lastCommit?.message).toBe('seed')
45+ expect(cat.snapshot.exists).toBe(true)
46+ expect(cat.snapshot.items.map((i) => i.name)).toContain('alpha')
47+ expect(await readFile(path.join(env.catalogDir, 'skills', 'alpha', 'SKILL.md'), 'utf8')).toContain('Body of alpha')
48+ })
49+
50+ it('clones into a given path and saves it as the catalog path', async () => {
51+ const target = path.join(env.tmp, 'elsewhere', 'catalog')
52+ const cat = (await (await env.send('POST', '/api/catalog/clone', { url: remote, path: target })).json()) as CatalogResponse
53+ expect(cat.snapshot.root).toBe(target)
54+ expect(((await (await env.req('/api/settings')).json()) as Settings).catalogPath).toBe(target)
55+ })
56+
57+ it('accepts an empty remote and creates the catalog layout', async () => {
58+ const empty = path.join(env.tmp, 'empty.git')
59+ execFileSync('git', ['init', '--bare', '-q', '-b', 'main', empty], { stdio: 'ignore' })
60+ const cat = (await (await env.send('POST', '/api/catalog/clone', { url: empty })).json()) as CatalogResponse
61+ expect(cat.git.isRepo).toBe(true)
62+ expect(cat.snapshot.exists).toBe(true)
63+ })
64+
65+ it('validates the body and refuses a non-empty target', async () => {
66+ expect((await env.send('POST', '/api/catalog/clone', {})).status).toBe(400)
67+ expect((await env.send('POST', '/api/catalog/clone', { url: '--upload-pack=x' })).status).toBe(400)
68+ expect((await env.send('POST', '/api/catalog/clone', { url: remote, path: 'relative' })).status).toBe(400)
69+ await env.send('POST', '/api/catalog/init')
70+ const res = await env.send('POST', '/api/catalog/clone', { url: remote })
71+ expect(res.status).toBe(400)
72+ expect(((await res.json()) as { error: string }).error).toMatch(/not empty|already/i)
73+ })
74+
75+ it('reports git errors and keeps the settings untouched', async () => {
76+ const target = path.join(env.tmp, 'never', 'catalog')
77+ const res = await env.send('POST', '/api/catalog/clone', { url: path.join(env.tmp, 'missing.git'), path: target })
78+ expect(res.status).toBe(400)
79+ expect(((await res.json()) as { error: string }).error).toBeTruthy()
80+ expect(((await (await env.req('/api/settings')).json()) as Settings).catalogPath).toBe(env.catalogDir)
81+ })
82+})
83+
84+describe('GET /api/fs/discover', () => {
85+ async function discover(root: string, depth?: number): Promise<Response> {
86+ const q = `root=${encodeURIComponent(root)}${depth === undefined ? '' : `&depth=${depth}`}`
87+ return env.req(`/api/fs/discover?${q}`)
88+ }
89+
90+ it('finds folders with agent config, skipping hidden, node_modules and nested projects', async () => {
91+ const root = path.join(env.tmp, 'code')
92+ await put(path.join(root, 'app-a', 'CLAUDE.md'), '# a')
93+ await put(path.join(root, 'app-a', 'packages', 'inner', 'AGENTS.md'), '# not reported: parent is a project')
94+ await put(path.join(root, 'group', 'app-b', '.claude', 'settings.json'), '{}')
95+ await put(path.join(root, 'group', 'app-b', 'AGENTS.md'), '# b')
96+ await put(path.join(root, 'plain', 'readme.md'), 'x')
97+ await put(path.join(root, 'node_modules', 'dep', 'CLAUDE.md'), 'x')
98+ await put(path.join(root, '.hidden', 'CLAUDE.md'), 'x')
99+ const res = await discover(root, 3)
100+ expect(res.status).toBe(200)
101+ const body = (await res.json()) as FsDiscovery
102+ expect(body.truncated).toBe(false)
103+ expect(body.entries.map((e) => e.name)).toEqual(['app-a', 'app-b'])
104+ expect(body.entries[1]!.markers).toEqual(['.claude', 'AGENTS.md'])
105+ expect(body.entries.every((e) => e.hasAgentConfig)).toBe(true)
106+ })
107+
108+ it('honours depth and clamps nothing silently', async () => {
109+ const root = path.join(env.tmp, 'code')
110+ await put(path.join(root, 'group', 'deep', 'CLAUDE.md'), 'x')
111+ expect(((await (await discover(root, 1)).json()) as FsDiscovery).entries).toEqual([])
112+ expect(((await (await discover(root, 2)).json()) as FsDiscovery).entries.map((e) => e.name)).toEqual(['deep'])
113+ expect((await discover(root, 5)).status).toBe(400)
114+ expect((await discover(root, 0)).status).toBe(400)
115+ })
116+
117+ it('validates the root', async () => {
118+ expect((await env.req('/api/fs/discover')).status).toBe(400)
119+ expect((await discover('relative/path')).status).toBe(400)
120+ expect((await discover(path.join(env.tmp, 'missing'))).status).toBe(404)
121+ })
122+})
new file mode 100644
@@ -0,0 +1,122 @@
1+import { execFileSync } from 'node:child_process'
2+import { mkdir, readFile } from 'node:fs/promises'
3+import path from 'node:path'
4+import type { CatalogResponse, FsDiscovery, Settings } from '@skilly/core'
5+import { afterEach, beforeEach, describe, expect, it } from 'vitest'
6+import { fakeEngine, git, makeEnv, put, skillMd, type Env } from './helpers.ts'
7+
8+let env: Env
9+
10+beforeEach(async () => {
11+ env = await makeEnv({ syncEngine: fakeEngine() })
12+})
13+
14+afterEach(() => env.cleanup())
15+
16+describe('POST /api/catalog/clone', { timeout: 60_000 }, () => {
17+ let remote: string
18+
19+ /** A bare remote holding one skill, pushed from a scratch clone. */
20+ async function populatedRemote(): Promise<string> {
21+ const bare = path.join(env.tmp, 'remote.git')
22+ await mkdir(bare, { recursive: true })
23+ execFileSync('git', ['init', '--bare', '-q', '-b', 'main', bare], { stdio: 'ignore' })
24+ const seed = path.join(env.tmp, 'seed')
25+ await mkdir(seed, { recursive: true })
26+ git(seed, 'init', '-q', '-b', 'main')
27+ await put(path.join(seed, 'skills', 'alpha', 'SKILL.md'), skillMd('alpha'))
28+ git(seed, 'add', '-A')
29+ git(seed, 'commit', '-q', '-m', 'seed')
30+ git(seed, 'push', '-q', bare, 'main')
31+ return bare
32+ }
33+
34+ beforeEach(async () => {
35+ remote = await populatedRemote()
36+ })
37+
38+ it('clones into the current catalog path and returns the catalog', async () => {
39+ const res = await env.send('POST', '/api/catalog/clone', { url: remote })
40+ expect(res.status).toBe(200)
41+ const cat = (await res.json()) as CatalogResponse
42+ expect(cat.git.isRepo).toBe(true)
43+ expect(cat.git.remote).toBeTruthy()
44+ expect(cat.git.lastCommit?.message).toBe('seed')
45+ expect(cat.snapshot.exists).toBe(true)
46+ expect(cat.snapshot.items.map((i) => i.name)).toContain('alpha')
47+ expect(await readFile(path.join(env.catalogDir, 'skills', 'alpha', 'SKILL.md'), 'utf8')).toContain('Body of alpha')
48+ })
49+
50+ it('clones into a given path and saves it as the catalog path', async () => {
51+ const target = path.join(env.tmp, 'elsewhere', 'catalog')
52+ const cat = (await (await env.send('POST', '/api/catalog/clone', { url: remote, path: target })).json()) as CatalogResponse
53+ expect(cat.snapshot.root).toBe(target)
54+ expect(((await (await env.req('/api/settings')).json()) as Settings).catalogPath).toBe(target)
55+ })
56+
57+ it('accepts an empty remote and creates the catalog layout', async () => {
58+ const empty = path.join(env.tmp, 'empty.git')
59+ execFileSync('git', ['init', '--bare', '-q', '-b', 'main', empty], { stdio: 'ignore' })
60+ const cat = (await (await env.send('POST', '/api/catalog/clone', { url: empty })).json()) as CatalogResponse
61+ expect(cat.git.isRepo).toBe(true)
62+ expect(cat.snapshot.exists).toBe(true)
63+ })
64+
65+ it('validates the body and refuses a non-empty target', async () => {
66+ expect((await env.send('POST', '/api/catalog/clone', {})).status).toBe(400)
67+ expect((await env.send('POST', '/api/catalog/clone', { url: '--upload-pack=x' })).status).toBe(400)
68+ expect((await env.send('POST', '/api/catalog/clone', { url: remote, path: 'relative' })).status).toBe(400)
69+ await env.send('POST', '/api/catalog/init')
70+ const res = await env.send('POST', '/api/catalog/clone', { url: remote })
71+ expect(res.status).toBe(400)
72+ expect(((await res.json()) as { error: string }).error).toMatch(/not empty|already/i)
73+ })
74+
75+ it('reports git errors and keeps the settings untouched', async () => {
76+ const target = path.join(env.tmp, 'never', 'catalog')
77+ const res = await env.send('POST', '/api/catalog/clone', { url: path.join(env.tmp, 'missing.git'), path: target })
78+ expect(res.status).toBe(400)
79+ expect(((await res.json()) as { error: string }).error).toBeTruthy()
80+ expect(((await (await env.req('/api/settings')).json()) as Settings).catalogPath).toBe(env.catalogDir)
81+ })
82+})
83+
84+describe('GET /api/fs/discover', () => {
85+ async function discover(root: string, depth?: number): Promise<Response> {
86+ const q = `root=${encodeURIComponent(root)}${depth === undefined ? '' : `&depth=${depth}`}`
87+ return env.req(`/api/fs/discover?${q}`)
88+ }
89+
90+ it('finds folders with agent config, skipping hidden, node_modules and nested projects', async () => {
91+ const root = path.join(env.tmp, 'code')
92+ await put(path.join(root, 'app-a', 'CLAUDE.md'), '# a')
93+ await put(path.join(root, 'app-a', 'packages', 'inner', 'AGENTS.md'), '# not reported: parent is a project')
94+ await put(path.join(root, 'group', 'app-b', '.claude', 'settings.json'), '{}')
95+ await put(path.join(root, 'group', 'app-b', 'AGENTS.md'), '# b')
96+ await put(path.join(root, 'plain', 'readme.md'), 'x')
97+ await put(path.join(root, 'node_modules', 'dep', 'CLAUDE.md'), 'x')
98+ await put(path.join(root, '.hidden', 'CLAUDE.md'), 'x')
99+ const res = await discover(root, 3)
100+ expect(res.status).toBe(200)
101+ const body = (await res.json()) as FsDiscovery
102+ expect(body.truncated).toBe(false)
103+ expect(body.entries.map((e) => e.name)).toEqual(['app-a', 'app-b'])
104+ expect(body.entries[1]!.markers).toEqual(['.claude', 'AGENTS.md'])
105+ expect(body.entries.every((e) => e.hasAgentConfig)).toBe(true)
106+ })
107+
108+ it('honours depth and clamps nothing silently', async () => {
109+ const root = path.join(env.tmp, 'code')
110+ await put(path.join(root, 'group', 'deep', 'CLAUDE.md'), 'x')
111+ expect(((await (await discover(root, 1)).json()) as FsDiscovery).entries).toEqual([])
112+ expect(((await (await discover(root, 2)).json()) as FsDiscovery).entries.map((e) => e.name)).toEqual(['deep'])
113+ expect((await discover(root, 5)).status).toBe(400)
114+ expect((await discover(root, 0)).status).toBe(400)
115+ })
116+
117+ it('validates the root', async () => {
118+ expect((await env.req('/api/fs/discover')).status).toBe(400)
119+ expect((await discover('relative/path')).status).toBe(400)
120+ expect((await discover(path.join(env.tmp, 'missing'))).status).toBe(404)
121+ })
122+})
modified packages/ui/app/assets/css/main.css +0 -18
@@ -210,21 +210,3 @@ body {
210210 a {
211211 text-underline-offset: 2px;
212212 }
213-
214-/* ------------------------------------------------------------------ markdown (legacy; owned by WP2 once MarkdownView is rebuilt) */
215-.md-body { font-size: 13px; line-height: 1.6; overflow-wrap: anywhere; }
216-.md-body h1 { font-size: 1.25rem; font-weight: 600; margin: 1rem 0 0.5rem; }
217-.md-body h2 { font-size: 1.05rem; font-weight: 600; margin: 1rem 0 0.5rem; }
218-.md-body h3, .md-body h4 { font-size: 0.95rem; font-weight: 600; margin: 0.75rem 0 0.4rem; }
219-.md-body p { margin: 0.5rem 0; }
220-.md-body ul { list-style: disc; padding-left: 1.25rem; margin: 0.5rem 0; }
221-.md-body ol { list-style: decimal; padding-left: 1.25rem; margin: 0.5rem 0; }
222-.md-body a { color: var(--sk-accent-text); text-decoration: underline; }
223-.md-body code { font-family: var(--font-mono); background: var(--sk-line2); padding: 0.1rem 0.3rem; border-radius: 0.25rem; font-size: 0.78rem; }
224-.md-body pre { background: var(--sk-hover); box-shadow: 0 0 0 1px var(--sk-ring); padding: 0.75rem; border-radius: 10px; overflow-x: auto; margin: 0.5rem 0; }
225-.md-body pre code { background: transparent; padding: 0; }
226-.md-body blockquote { border-left: 3px solid var(--sk-line); padding-left: 0.75rem; color: var(--sk-muted); margin: 0.5rem 0; }
227-.md-body table { display: block; max-width: 100%; overflow-x: auto; border-collapse: collapse; margin: 0.5rem 0; }
228-.md-body th, .md-body td { border: 1px solid var(--sk-line); padding: 0.25rem 0.5rem; text-align: left; vertical-align: top; }
229-.md-body th { background: var(--sk-hover); font-weight: 600; }
230-.md-body hr { border-color: var(--sk-line); margin: 1rem 0; }
@@ -210,21 +210,3 @@ body {
210 a {210 a {
211 text-underline-offset: 2px;211 text-underline-offset: 2px;
212 }212 }
213-
214-/* ------------------------------------------------------------------ markdown (legacy; owned by WP2 once MarkdownView is rebuilt) */
215-.md-body { font-size: 13px; line-height: 1.6; overflow-wrap: anywhere; }
216-.md-body h1 { font-size: 1.25rem; font-weight: 600; margin: 1rem 0 0.5rem; }
217-.md-body h2 { font-size: 1.05rem; font-weight: 600; margin: 1rem 0 0.5rem; }
218-.md-body h3, .md-body h4 { font-size: 0.95rem; font-weight: 600; margin: 0.75rem 0 0.4rem; }
219-.md-body p { margin: 0.5rem 0; }
220-.md-body ul { list-style: disc; padding-left: 1.25rem; margin: 0.5rem 0; }
221-.md-body ol { list-style: decimal; padding-left: 1.25rem; margin: 0.5rem 0; }
222-.md-body a { color: var(--sk-accent-text); text-decoration: underline; }
223-.md-body code { font-family: var(--font-mono); background: var(--sk-line2); padding: 0.1rem 0.3rem; border-radius: 0.25rem; font-size: 0.78rem; }
224-.md-body pre { background: var(--sk-hover); box-shadow: 0 0 0 1px var(--sk-ring); padding: 0.75rem; border-radius: 10px; overflow-x: auto; margin: 0.5rem 0; }
225-.md-body pre code { background: transparent; padding: 0; }
226-.md-body blockquote { border-left: 3px solid var(--sk-line); padding-left: 0.75rem; color: var(--sk-muted); margin: 0.5rem 0; }
227-.md-body table { display: block; max-width: 100%; overflow-x: auto; border-collapse: collapse; margin: 0.5rem 0; }
228-.md-body th, .md-body td { border: 1px solid var(--sk-line); padding: 0.25rem 0.5rem; text-align: left; vertical-align: top; }
229-.md-body th { background: var(--sk-hover); font-weight: 600; }
230-.md-body hr { border-color: var(--sk-line); margin: 1rem 0; }
modified packages/ui/app/components/AddProjectModal.vue +22 -41
@@ -1,4 +1,5 @@
11 <script setup lang="ts">
2+/** "Add project" modal: paste a folder path or browse to it. Mounted once in the default layout. */
23 const open = defineModel<boolean>('open', { default: false })
34
45 const { add } = useProjects()
@@ -8,7 +9,7 @@ const toast = useToast()
89 const pathInput = ref('')
910 const submitting = ref(false)
1011 const submitError = ref<string | null>(null)
11-// The highlighted entry wins over the folder being browsed, so "Select" adds what the user clicked.
12+// The highlighted entry wins over the folder being browsed, so "Select this folder" adds what the user clicked.
1213 const selectedPath = ref<string | null>(null)
1314
1415 async function submit(path: string) {
@@ -39,46 +40,26 @@ watch(open, (v) => {
3940 </script>
4041
4142 <template>
42- <UModal
43- v-model:open="open"
44- title="Add project"
45- description="Browse to a project folder, or paste its path."
46- :ui="{ content: 'max-w-2xl' }"
47- >
48- <template #body>
49- <div class="space-y-4">
50- <form class="flex gap-2" @submit.prevent="submit(pathInput)">
51- <UInput
52- v-model="pathInput"
53- icon="i-lucide-folder-input"
54- placeholder="Paste a folder path, e.g. G:\Dev\my-project"
55- class="flex-1"
56- autofocus
57- @update:model-value="submitError = null"
58- />
59- <UButton type="submit" label="Add" :loading="submitting" :disabled="!pathInput.trim()" />
60- </form>
61- <UAlert v-if="submitError" color="error" variant="subtle" icon="i-lucide-circle-alert" :title="submitError" />
62-
63- <FolderBrowser v-if="open" v-model:selected="selectedPath" />
64- </div>
65- </template>
66-
43+ <SkModal v-model:open="open" title="Add project" description="Browse to a project folder, or paste its path." width="lg">
44+ <div class="flex flex-col gap-3.5">
45+ <form class="flex flex-col gap-1.5" @submit.prevent="submit(pathInput)">
46+ <UInput
47+ v-model="pathInput"
48+ placeholder="Paste a folder path, e.g. G:\Dev\my-project"
49+ class="w-full font-mono"
50+ :class="submitError && '[&_input]:ring-sk-accent'"
51+ autofocus
52+ aria-label="Project folder path"
53+ @update:model-value="submitError = null"
54+ />
55+ <p v-if="submitError" class="m-0 text-xs text-sk-accent-text">{{ submitError }}</p>
56+ </form>
57+ <FolderBrowser v-if="open" v-model:selected="selectedPath" />
58+ </div>
6759 <template #footer>
68- <div class="flex w-full items-center justify-between gap-2">
69- <span v-if="selectedPath" class="truncate font-mono text-xs text-muted" :title="selectedPath">{{ selectedPath }}</span>
70- <span v-else class="truncate text-xs text-muted">Select a drive or folder</span>
71- <div class="flex shrink-0 gap-2">
72- <UButton label="Cancel" color="neutral" variant="outline" @click="open = false" />
73- <UButton
74- label="Select this folder"
75- icon="i-lucide-check"
76- :disabled="!selectedPath"
77- :loading="submitting"
78- @click="submit(selectedPath!)"
79- />
80- </div>
81- </div>
60+ <span class="mr-auto min-w-0 truncate font-mono text-[11px] text-sk-faint" :title="selectedPath ?? ''">{{ selectedPath ?? 'Select a drive or folder' }}</span>
61+ <SkButton label="Cancel" @click="open = false" />
62+ <SkButton variant="primary" label="Select this folder" :disabled="!(pathInput.trim() || selectedPath)" :loading="submitting" @click="submit(pathInput.trim() || selectedPath!)" />
8263 </template>
83- </UModal>
64+ </SkModal>
8465 </template>
@@ -1,4 +1,5 @@
1 <script setup lang="ts">1 <script setup lang="ts">
2+/** "Add project" modal: paste a folder path or browse to it. Mounted once in the default layout. */
2 const open = defineModel<boolean>('open', { default: false })3 const open = defineModel<boolean>('open', { default: false })
3 4
4 const { add } = useProjects()5 const { add } = useProjects()
@@ -8,7 +9,7 @@ const toast = useToast()
8 const pathInput = ref('')9 const pathInput = ref('')
9 const submitting = ref(false)10 const submitting = ref(false)
10 const submitError = ref<string | null>(null)11 const submitError = ref<string | null>(null)
11-// The highlighted entry wins over the folder being browsed, so "Select" adds what the user clicked.12+// The highlighted entry wins over the folder being browsed, so "Select this folder" adds what the user clicked.
12 const selectedPath = ref<string | null>(null)13 const selectedPath = ref<string | null>(null)
13 14
14 async function submit(path: string) {15 async function submit(path: string) {
@@ -39,46 +40,26 @@ watch(open, (v) => {
39 </script>40 </script>
40 41
41 <template>42 <template>
42- <UModal43+ <SkModal v-model:open="open" title="Add project" description="Browse to a project folder, or paste its path." width="lg">
43- v-model:open="open"44+ <div class="flex flex-col gap-3.5">
44- title="Add project"45+ <form class="flex flex-col gap-1.5" @submit.prevent="submit(pathInput)">
45- description="Browse to a project folder, or paste its path."46+ <UInput
46- :ui="{ content: 'max-w-2xl' }"47+ v-model="pathInput"
47- >48+ placeholder="Paste a folder path, e.g. G:\Dev\my-project"
48- <template #body>49+ class="w-full font-mono"
49- <div class="space-y-4">50+ :class="submitError && '[&_input]:ring-sk-accent'"
50- <form class="flex gap-2" @submit.prevent="submit(pathInput)">51+ autofocus
51- <UInput52+ aria-label="Project folder path"
52- v-model="pathInput"53+ @update:model-value="submitError = null"
53- icon="i-lucide-folder-input"54+ />
54- placeholder="Paste a folder path, e.g. G:\Dev\my-project"55+ <p v-if="submitError" class="m-0 text-xs text-sk-accent-text">{{ submitError }}</p>
55- class="flex-1"56+ </form>
56- autofocus57+ <FolderBrowser v-if="open" v-model:selected="selectedPath" />
57- @update:model-value="submitError = null"58+ </div>
58- />
59- <UButton type="submit" label="Add" :loading="submitting" :disabled="!pathInput.trim()" />
60- </form>
61- <UAlert v-if="submitError" color="error" variant="subtle" icon="i-lucide-circle-alert" :title="submitError" />
62-
63- <FolderBrowser v-if="open" v-model:selected="selectedPath" />
64- </div>
65- </template>
66-
67 <template #footer>59 <template #footer>
68- <div class="flex w-full items-center justify-between gap-2">60+ <span class="mr-auto min-w-0 truncate font-mono text-[11px] text-sk-faint" :title="selectedPath ?? ''">{{ selectedPath ?? 'Select a drive or folder' }}</span>
69- <span v-if="selectedPath" class="truncate font-mono text-xs text-muted" :title="selectedPath">{{ selectedPath }}</span>61+ <SkButton label="Cancel" @click="open = false" />
70- <span v-else class="truncate text-xs text-muted">Select a drive or folder</span>62+ <SkButton variant="primary" label="Select this folder" :disabled="!(pathInput.trim() || selectedPath)" :loading="submitting" @click="submit(pathInput.trim() || selectedPath!)" />
71- <div class="flex shrink-0 gap-2">
72- <UButton label="Cancel" color="neutral" variant="outline" @click="open = false" />
73- <UButton
74- label="Select this folder"
75- icon="i-lucide-check"
76- :disabled="!selectedPath"
77- :loading="submitting"
78- @click="submit(selectedPath!)"
79- />
80- </div>
81- </div>
82 </template>63 </template>
83- </UModal>64+ </SkModal>
84 </template>65 </template>
modified packages/ui/app/components/AddToCatalogModal.vue +32 -38
@@ -1,6 +1,7 @@
11 <script setup lang="ts">
22 import type { ImportResult, InventoryItem, ItemStatus } from '@skilly/core'
33
4+/** "Add to catalog": copies local items into the catalog (tags, "overwrite" when a different version exists). */
45 const props = defineProps<{ scope: string, items: InventoryItem[], statuses: Record<string, ItemStatus> }>()
56 const open = defineModel<boolean>('open', { default: false })
67 const emit = defineEmits<{ done: [] }>()
@@ -46,45 +47,38 @@ async function submit() {
4647 </script>
4748
4849 <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" add-on-blur class="w-full" placeholder="Add a tag, press Enter" />
80- </UFormField>
50+ <SkModal v-model:open="open" title="Add to catalog" description="Copy these items into the catalog so they can be reused in other projects.">
51+ <div v-if="result" class="flex flex-col gap-3">
52+ <SkNote v-if="result.imported.length" tone="ok">
53+ <span class="font-medium">{{ result.imported.length }} added</span>
54+ <span class="block font-mono text-xs text-sk-muted">{{ result.imported.map(i => `${i.type}/${i.name}`).join(', ') }}</span>
55+ </SkNote>
56+ <SkNote v-for="s in result.skipped" :key="s.id" tone="accent"><span class="font-mono text-xs">{{ s.id }}</span>: {{ s.reason }}</SkNote>
57+ </div>
58+
59+ <div v-else class="flex flex-col gap-4">
60+ <SkNote v-if="error" tone="accent"><span class="font-medium">Import failed.</span> {{ error }}</SkNote>
61+ <ul class="m-0 max-h-48 list-none overflow-auto rounded-[10px] border border-sk-line p-0">
62+ <li v-for="i in items" :key="i.id" class="flex items-center gap-2.5 border-t border-sk-line2 px-3 py-2 first:border-t-0">
63+ <span class="truncate font-medium">{{ itemDisplayName(i) }}</span>
64+ <SkTypeTag :type="i.type" />
65+ <SkAgentBadge :agent="i.agent" class="text-xs" />
66+ <SkStatus v-if="statuses[i.id]?.state === 'differs-from-catalog'" tone="accent" label="Differs from catalog" small class="ml-auto text-xs text-sk-muted" />
67+ </li>
68+ </ul>
69+ <SkNote v-if="differing.length" tone="accent">
70+ <p class="m-0">{{ differing.length }} item{{ differing.length === 1 ? ' already exists' : 's already exist' }} in the catalog with different content.</p>
71+ <UCheckbox v-model="overwrite" label="Overwrite the catalog version" description="Unchecked, these items are left out." class="mt-2 text-sk-text" />
72+ </SkNote>
73+ <div class="flex flex-col gap-1.5">
74+ <SkSectionLabel>Tags</SkSectionLabel>
75+ <UInputTags v-model="tags" add-on-blur class="w-full" placeholder="Add a tag, press Enter (optional)" />
8176 </div>
82- </template>
77+ </div>
78+
8379 <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>
80+ <SkButton :label="result ? 'Close' : 'Cancel'" @click="open = false" />
81+ <SkButton v-if="!result" variant="primary" :label="`Add ${toSend.length} to catalog`" :loading="busy" :disabled="!toSend.length" @click="submit" />
8882 </template>
89- </UModal>
83+ </SkModal>
9084 </template>
@@ -1,6 +1,7 @@
1 <script setup lang="ts">1 <script setup lang="ts">
2 import type { ImportResult, InventoryItem, ItemStatus } from '@skilly/core'2 import type { ImportResult, InventoryItem, ItemStatus } from '@skilly/core'
3 3
4+/** "Add to catalog": copies local items into the catalog (tags, "overwrite" when a different version exists). */
4 const props = defineProps<{ scope: string, items: InventoryItem[], statuses: Record<string, ItemStatus> }>()5 const props = defineProps<{ scope: string, items: InventoryItem[], statuses: Record<string, ItemStatus> }>()
5 const open = defineModel<boolean>('open', { default: false })6 const open = defineModel<boolean>('open', { default: false })
6 const emit = defineEmits<{ done: [] }>()7 const emit = defineEmits<{ done: [] }>()
@@ -46,45 +47,38 @@ async function submit() {
46 </script>47 </script>
47 48
48 <template>49 <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+ <SkModal v-model:open="open" title="Add to catalog" description="Copy these items into the catalog so they can be reused in other projects.">
50- <template #body>51+ <div v-if="result" class="flex flex-col gap-3">
51- <div v-if="result" class="space-y-3 text-sm">52+ <SkNote v-if="result.imported.length" tone="ok">
52- <UAlert v-if="result.imported.length" color="success" variant="subtle" icon="i-lucide-check" :title="`${result.imported.length} item(s) added`">53+ <span class="font-medium">{{ result.imported.length }} added</span>
53- <template #description>54+ <span class="block font-mono text-xs text-sk-muted">{{ result.imported.map(i => `${i.type}/${i.name}`).join(', ') }}</span>
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+ </SkNote>
55- </template>56+ <SkNote v-for="s in result.skipped" :key="s.id" tone="accent"><span class="font-mono text-xs">{{ s.id }}</span>: {{ s.reason }}</SkNote>
56- </UAlert>57+ </div>
57- <UAlert v-if="result.skipped.length" color="warning" variant="subtle" icon="i-lucide-triangle-alert" :title="`${result.skipped.length} skipped`">58+
58- <template #description>59+ <div v-else class="flex flex-col gap-4">
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+ <SkNote v-if="error" tone="accent"><span class="font-medium">Import failed.</span> {{ error }}</SkNote>
60- </template>61+ <ul class="m-0 max-h-48 list-none overflow-auto rounded-[10px] border border-sk-line p-0">
61- </UAlert>62+ <li v-for="i in items" :key="i.id" class="flex items-center gap-2.5 border-t border-sk-line2 px-3 py-2 first:border-t-0">
62- </div>63+ <span class="truncate font-medium">{{ itemDisplayName(i) }}</span>
63- <div v-else class="space-y-4">64+ <SkTypeTag :type="i.type" />
64- <UAlert v-if="error" color="error" variant="subtle" icon="i-lucide-circle-alert" title="Import failed" :description="error" />65+ <SkAgentBadge :agent="i.agent" class="text-xs" />
65- <ul class="max-h-48 divide-y divide-default overflow-auto rounded-lg border border-default text-sm">66+ <SkStatus v-if="statuses[i.id]?.state === 'differs-from-catalog'" tone="accent" label="Differs from catalog" small class="ml-auto text-xs text-sk-muted" />
66- <li v-for="i in items" :key="i.id" class="flex items-center gap-2 px-3 py-1.5">67+ </li>
67- <AgentBadge :agent="i.agent" compact />68+ </ul>
68- <span class="truncate font-medium">{{ i.name }}</span>69+ <SkNote v-if="differing.length" tone="accent">
69- <span class="text-xs text-muted">{{ i.type }}</span>70+ <p class="m-0">{{ differing.length }} item{{ differing.length === 1 ? ' already exists' : 's already exist' }} in the catalog with different content.</p>
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+ <UCheckbox v-model="overwrite" label="Overwrite the catalog version" description="Unchecked, these items are left out." class="mt-2 text-sk-text" />
71- </li>72+ </SkNote>
72- </ul>73+ <div class="flex flex-col gap-1.5">
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+ <SkSectionLabel>Tags</SkSectionLabel>
74- <template #description>75+ <UInputTags v-model="tags" add-on-blur class="w-full" placeholder="Add a tag, press Enter (optional)" />
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" add-on-blur class="w-full" placeholder="Add a tag, press Enter" />
80- </UFormField>
81 </div>76 </div>
82- </template>77+ </div>
78+
83 <template #footer>79 <template #footer>
84- <div class="flex w-full justify-end gap-2">80+ <SkButton :label="result ? 'Close' : 'Cancel'" @click="open = false" />
85- <UButton :label="result ? 'Close' : 'Cancel'" color="neutral" variant="outline" @click="open = false" />81+ <SkButton v-if="!result" variant="primary" :label="`Add ${toSend.length} to catalog`" :loading="busy" :disabled="!toSend.length" @click="submit" />
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>82 </template>
89- </UModal>83+ </SkModal>
90 </template>84 </template>
deleted packages/ui/app/components/ApplyModal.vue +0 -251
deleted file mode 100644
@@ -1,251 +0,0 @@
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.reason || (a.conflict ? CONFLICT_LABEL[a.conflict] : '') }}</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>
deleted file mode 100644
@@ -1,251 +0,0 @@
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.reason || (a.conflict ? CONFLICT_LABEL[a.conflict] : '') }}</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/CatalogBrowser.vue +139 -0
new file mode 100644
@@ -0,0 +1,139 @@
1+<script setup lang="ts">
2+import type { CatalogItem, CatalogItemType } from '@skilly/core'
3+
4+/**
5+ * Catalog items screen: header, left column (search, type chips, tag chips, list) and the detail panel.
6+ * `selected` = item shown (route `/catalog/[type]/[name]`), `creating` = type of a new draft (`/catalog/new`).
7+ * Filters live in shared state so they survive navigating between items.
8+ */
9+const props = defineProps<{ selected?: { type: CatalogItemType, name: string }, creating?: CatalogItemType }>()
10+
11+const { data, loading, error, refresh, exists, items, usage } = useCatalog()
12+const names = useScopeNames()
13+
14+const filters = useState('catalog-filters', () => ({ type: 'all' as 'all' | CatalogItemType, search: '', tags: [] as string[] }))
15+const route = useRoute()
16+if (!props.selected && !props.creating && CAT_TYPES.includes(route.query.type as CatalogItemType)) filters.value.type = route.query.type as CatalogItemType
17+
18+onMounted(refresh)
19+
20+const byType = computed(() => {
21+ const m = Object.fromEntries(CAT_TYPES.map(t => [t, [] as CatalogItem[]])) as Record<CatalogItemType, CatalogItem[]>
22+ for (const i of items.value) m[i.type]?.push(i)
23+ return m
24+})
25+
26+const pool = computed(() => (filters.value.type === 'all' ? items.value : byType.value[filters.value.type]))
27+const allTags = computed(() => [...new Set(pool.value.flatMap(i => i.tags))].sort())
28+watch(() => filters.value.type, () => { filters.value.tags = [] })
29+
30+const rows = computed(() => {
31+ const q = filters.value.search.trim().toLowerCase()
32+ return pool.value
33+ .filter(i => (!q || `${i.name} ${i.description ?? ''} ${i.tags.join(' ')}`.toLowerCase().includes(q))
34+ && filters.value.tags.every(t => i.tags.includes(t)))
35+ .sort((a, b) => a.name.localeCompare(b.name) || a.type.localeCompare(b.type))
36+})
37+const filtered = computed(() => !!filters.value.search.trim() || filters.value.tags.length > 0)
38+
39+const typeChips = computed(() => [
40+ { value: 'all' as const, label: 'All' },
41+ ...CAT_ORDER.map(t => ({ value: t, label: CAT_META[t].label }))
42+])
43+
44+function toggleTag(t: string) {
45+ const cur = filters.value.tags
46+ filters.value.tags = cur.includes(t) ? cur.filter(x => x !== t) : [...cur, t]
47+}
48+
49+const usedBy = (i: CatalogItem) => usage.value[itemKey(i.type, i.name)] ?? []
50+const isSel = (i: CatalogItem) => props.selected?.type === i.type && props.selected.name === i.name
51+const href = (i: CatalogItem) => `/catalog/${i.type}/${encodeURIComponent(i.name)}`
52+const ORIGIN_TAG = { import: 'imported', marketplace: 'marketplace' } as const
53+
54+const newType = computed<CatalogItemType>(() => props.selected?.type ?? props.creating ?? (filters.value.type === 'all' ? 'skill' : filters.value.type))
55+const errors = computed(() => data.value?.snapshot.errors ?? [])
56+const errorsOpen = ref(false)
57+</script>
58+
59+<template>
60+ <SkPage wide>
61+ <CatalogHeader tab="items" :new-type="newType" />
62+
63+ <div v-if="loading && !data" class="grid gap-4 md:grid-cols-[minmax(200px,280px)_minmax(0,1fr)]">
64+ <USkeleton class="h-96 rounded-[20px]" />
65+ <USkeleton class="h-96 rounded-[20px]" />
66+ </div>
67+ <SkNote v-else-if="error && !data" tone="accent">
68+ <p class="m-0 font-medium">Could not load the catalog</p>
69+ <p class="mt-1 mb-0 text-xs">{{ error }}</p>
70+ <template #aside><SkButton size="sm" label="Retry" @click="refresh" /></template>
71+ </SkNote>
72+ <CatalogSetupCard v-else-if="data && !exists" />
73+
74+ <template v-else-if="data">
75+ <SkNote v-if="errors.length" tone="accent">
76+ <button type="button" class="cursor-pointer border-0 bg-transparent p-0 text-left font-medium text-inherit" @click="errorsOpen = !errorsOpen">
77+ {{ errors.length }} item{{ errors.length > 1 ? 's' : '' }} could not be read
78+ <span class="ml-1 text-xs font-normal underline">{{ errorsOpen ? 'Hide' : 'Details' }}</span>
79+ </button>
80+ <ul v-if="errorsOpen" class="mt-1.5 mb-0 list-none p-0 text-xs">
81+ <li v-for="e in errors" :key="e.path"><span class="font-mono">{{ e.path }}</span>: {{ e.message }}</li>
82+ </ul>
83+ </SkNote>
84+
85+ <div class="grid items-start gap-4 md:grid-cols-[minmax(200px,280px)_minmax(0,1fr)]">
86+ <!-- Left column -->
87+ <div class="flex min-w-0 flex-col gap-2.5 md:sticky md:top-[76px]">
88+ <UInput v-model="filters.search" placeholder="Search name, tag or description" aria-label="Search the catalog" size="lg" class="w-full" :ui="{ base: 'bg-sk-panel' }">
89+ <template v-if="filters.search" #trailing>
90+ <SkButton size="xs" variant="ghost" icon="i-lucide-x" aria-label="Clear search" @click="filters.search = ''" />
91+ </template>
92+ </UInput>
93+ <div class="flex flex-wrap gap-1" role="group" aria-label="Filter by type">
94+ <SkChip v-for="c in typeChips" :key="c.value" :active="filters.type === c.value" @click="filters.type = c.value">{{ c.label }}</SkChip>
95+ </div>
96+ <div v-if="allTags.length" class="flex flex-wrap items-center gap-1" role="group" aria-label="Filter by tag">
97+ <SkSectionLabel class="mr-1">Tags</SkSectionLabel>
98+ <SkChip v-for="t in allTags" :key="t" :active="filters.tags.includes(t)" @click="toggleTag(t)">{{ t }}</SkChip>
99+ </div>
100+
101+ <SkPanel class="max-h-[calc(100vh-340px)] min-h-40 overflow-y-auto">
102+ <NuxtLink
103+ v-for="i in rows"
104+ :key="`${i.type}/${i.name}`"
105+ :to="href(i)"
106+ class="flex flex-col gap-0.5 border-t border-sk-line2 py-2.5 pr-[18px] pl-3.5 text-sk-text first:border-t-0 hover:bg-sk-hover"
107+ :class="isSel(i) && 'bg-sk-sel hover:bg-sk-sel'"
108+ :aria-current="isSel(i) ? 'page' : undefined"
109+ >
110+ <span class="flex items-baseline gap-2">
111+ <span class="min-w-0 flex-1 truncate font-medium">{{ i.name }}</span>
112+ <SkTypeTag :type="i.type" />
113+ </span>
114+ <span class="truncate text-xs text-sk-muted">{{ i.description || 'No description' }}</span>
115+ <span class="flex gap-2 font-mono text-[11px] text-sk-faint">
116+ <span v-if="i.origin && i.origin.kind !== 'created'">{{ ORIGIN_TAG[i.origin.kind] }}</span>
117+ <span v-if="usedBy(i).length" :title="usedBy(i).map(s => scopeLabel(s, names)).join(', ')">used in {{ usedBy(i).length }}</span>
118+ <span v-else>unused</span>
119+ </span>
120+ </NuxtLink>
121+ <div v-if="!rows.length" class="px-6 py-7 text-center text-sk-faint">
122+ <p class="m-0">{{ filtered ? 'No items match.' : `No ${filters.type === 'all' ? 'items' : CAT_META[filters.type].label.toLowerCase()} in the catalog yet.` }}</p>
123+ <SkButton v-if="filtered" class="mt-2" size="xs" variant="ghost" label="Clear filters" @click="filters.search = ''; filters.tags = []" />
124+ </div>
125+ </SkPanel>
126+ </div>
127+
128+ <!-- Detail -->
129+ <CatalogItemEditor v-if="creating" :key="`new/${creating}`" :type="creating" />
130+ <CatalogItemEditor v-else-if="selected" :key="`${selected.type}/${selected.name}`" :type="selected.type" :name="selected.name" />
131+ <SkPanel v-else>
132+ <SkEmpty :title="items.length ? 'Pick an item' : 'Your catalog is empty'">
133+ {{ items.length ? 'Select an item on the left to read or edit it.' : 'Create an item, add one from a project with "Add to catalog", or import from Marketplaces.' }}
134+ </SkEmpty>
135+ </SkPanel>
136+ </div>
137+ </template>
138+ </SkPage>
139+</template>
new file mode 100644
@@ -0,0 +1,139 @@
1+<script setup lang="ts">
2+import type { CatalogItem, CatalogItemType } from '@skilly/core'
3+
4+/**
5+ * Catalog items screen: header, left column (search, type chips, tag chips, list) and the detail panel.
6+ * `selected` = item shown (route `/catalog/[type]/[name]`), `creating` = type of a new draft (`/catalog/new`).
7+ * Filters live in shared state so they survive navigating between items.
8+ */
9+const props = defineProps<{ selected?: { type: CatalogItemType, name: string }, creating?: CatalogItemType }>()
10+
11+const { data, loading, error, refresh, exists, items, usage } = useCatalog()
12+const names = useScopeNames()
13+
14+const filters = useState('catalog-filters', () => ({ type: 'all' as 'all' | CatalogItemType, search: '', tags: [] as string[] }))
15+const route = useRoute()
16+if (!props.selected && !props.creating && CAT_TYPES.includes(route.query.type as CatalogItemType)) filters.value.type = route.query.type as CatalogItemType
17+
18+onMounted(refresh)
19+
20+const byType = computed(() => {
21+ const m = Object.fromEntries(CAT_TYPES.map(t => [t, [] as CatalogItem[]])) as Record<CatalogItemType, CatalogItem[]>
22+ for (const i of items.value) m[i.type]?.push(i)
23+ return m
24+})
25+
26+const pool = computed(() => (filters.value.type === 'all' ? items.value : byType.value[filters.value.type]))
27+const allTags = computed(() => [...new Set(pool.value.flatMap(i => i.tags))].sort())
28+watch(() => filters.value.type, () => { filters.value.tags = [] })
29+
30+const rows = computed(() => {
31+ const q = filters.value.search.trim().toLowerCase()
32+ return pool.value
33+ .filter(i => (!q || `${i.name} ${i.description ?? ''} ${i.tags.join(' ')}`.toLowerCase().includes(q))
34+ && filters.value.tags.every(t => i.tags.includes(t)))
35+ .sort((a, b) => a.name.localeCompare(b.name) || a.type.localeCompare(b.type))
36+})
37+const filtered = computed(() => !!filters.value.search.trim() || filters.value.tags.length > 0)
38+
39+const typeChips = computed(() => [
40+ { value: 'all' as const, label: 'All' },
41+ ...CAT_ORDER.map(t => ({ value: t, label: CAT_META[t].label }))
42+])
43+
44+function toggleTag(t: string) {
45+ const cur = filters.value.tags
46+ filters.value.tags = cur.includes(t) ? cur.filter(x => x !== t) : [...cur, t]
47+}
48+
49+const usedBy = (i: CatalogItem) => usage.value[itemKey(i.type, i.name)] ?? []
50+const isSel = (i: CatalogItem) => props.selected?.type === i.type && props.selected.name === i.name
51+const href = (i: CatalogItem) => `/catalog/${i.type}/${encodeURIComponent(i.name)}`
52+const ORIGIN_TAG = { import: 'imported', marketplace: 'marketplace' } as const
53+
54+const newType = computed<CatalogItemType>(() => props.selected?.type ?? props.creating ?? (filters.value.type === 'all' ? 'skill' : filters.value.type))
55+const errors = computed(() => data.value?.snapshot.errors ?? [])
56+const errorsOpen = ref(false)
57+</script>
58+
59+<template>
60+ <SkPage wide>
61+ <CatalogHeader tab="items" :new-type="newType" />
62+
63+ <div v-if="loading && !data" class="grid gap-4 md:grid-cols-[minmax(200px,280px)_minmax(0,1fr)]">
64+ <USkeleton class="h-96 rounded-[20px]" />
65+ <USkeleton class="h-96 rounded-[20px]" />
66+ </div>
67+ <SkNote v-else-if="error && !data" tone="accent">
68+ <p class="m-0 font-medium">Could not load the catalog</p>
69+ <p class="mt-1 mb-0 text-xs">{{ error }}</p>
70+ <template #aside><SkButton size="sm" label="Retry" @click="refresh" /></template>
71+ </SkNote>
72+ <CatalogSetupCard v-else-if="data && !exists" />
73+
74+ <template v-else-if="data">
75+ <SkNote v-if="errors.length" tone="accent">
76+ <button type="button" class="cursor-pointer border-0 bg-transparent p-0 text-left font-medium text-inherit" @click="errorsOpen = !errorsOpen">
77+ {{ errors.length }} item{{ errors.length > 1 ? 's' : '' }} could not be read
78+ <span class="ml-1 text-xs font-normal underline">{{ errorsOpen ? 'Hide' : 'Details' }}</span>
79+ </button>
80+ <ul v-if="errorsOpen" class="mt-1.5 mb-0 list-none p-0 text-xs">
81+ <li v-for="e in errors" :key="e.path"><span class="font-mono">{{ e.path }}</span>: {{ e.message }}</li>
82+ </ul>
83+ </SkNote>
84+
85+ <div class="grid items-start gap-4 md:grid-cols-[minmax(200px,280px)_minmax(0,1fr)]">
86+ <!-- Left column -->
87+ <div class="flex min-w-0 flex-col gap-2.5 md:sticky md:top-[76px]">
88+ <UInput v-model="filters.search" placeholder="Search name, tag or description" aria-label="Search the catalog" size="lg" class="w-full" :ui="{ base: 'bg-sk-panel' }">
89+ <template v-if="filters.search" #trailing>
90+ <SkButton size="xs" variant="ghost" icon="i-lucide-x" aria-label="Clear search" @click="filters.search = ''" />
91+ </template>
92+ </UInput>
93+ <div class="flex flex-wrap gap-1" role="group" aria-label="Filter by type">
94+ <SkChip v-for="c in typeChips" :key="c.value" :active="filters.type === c.value" @click="filters.type = c.value">{{ c.label }}</SkChip>
95+ </div>
96+ <div v-if="allTags.length" class="flex flex-wrap items-center gap-1" role="group" aria-label="Filter by tag">
97+ <SkSectionLabel class="mr-1">Tags</SkSectionLabel>
98+ <SkChip v-for="t in allTags" :key="t" :active="filters.tags.includes(t)" @click="toggleTag(t)">{{ t }}</SkChip>
99+ </div>
100+
101+ <SkPanel class="max-h-[calc(100vh-340px)] min-h-40 overflow-y-auto">
102+ <NuxtLink
103+ v-for="i in rows"
104+ :key="`${i.type}/${i.name}`"
105+ :to="href(i)"
106+ class="flex flex-col gap-0.5 border-t border-sk-line2 py-2.5 pr-[18px] pl-3.5 text-sk-text first:border-t-0 hover:bg-sk-hover"
107+ :class="isSel(i) && 'bg-sk-sel hover:bg-sk-sel'"
108+ :aria-current="isSel(i) ? 'page' : undefined"
109+ >
110+ <span class="flex items-baseline gap-2">
111+ <span class="min-w-0 flex-1 truncate font-medium">{{ i.name }}</span>
112+ <SkTypeTag :type="i.type" />
113+ </span>
114+ <span class="truncate text-xs text-sk-muted">{{ i.description || 'No description' }}</span>
115+ <span class="flex gap-2 font-mono text-[11px] text-sk-faint">
116+ <span v-if="i.origin && i.origin.kind !== 'created'">{{ ORIGIN_TAG[i.origin.kind] }}</span>
117+ <span v-if="usedBy(i).length" :title="usedBy(i).map(s => scopeLabel(s, names)).join(', ')">used in {{ usedBy(i).length }}</span>
118+ <span v-else>unused</span>
119+ </span>
120+ </NuxtLink>
121+ <div v-if="!rows.length" class="px-6 py-7 text-center text-sk-faint">
122+ <p class="m-0">{{ filtered ? 'No items match.' : `No ${filters.type === 'all' ? 'items' : CAT_META[filters.type].label.toLowerCase()} in the catalog yet.` }}</p>
123+ <SkButton v-if="filtered" class="mt-2" size="xs" variant="ghost" label="Clear filters" @click="filters.search = ''; filters.tags = []" />
124+ </div>
125+ </SkPanel>
126+ </div>
127+
128+ <!-- Detail -->
129+ <CatalogItemEditor v-if="creating" :key="`new/${creating}`" :type="creating" />
130+ <CatalogItemEditor v-else-if="selected" :key="`${selected.type}/${selected.name}`" :type="selected.type" :name="selected.name" />
131+ <SkPanel v-else>
132+ <SkEmpty :title="items.length ? 'Pick an item' : 'Your catalog is empty'">
133+ {{ items.length ? 'Select an item on the left to read or edit it.' : 'Create an item, add one from a project with "Add to catalog", or import from Marketplaces.' }}
134+ </SkEmpty>
135+ </SkPanel>
136+ </div>
137+ </template>
138+ </SkPage>
139+</template>
added packages/ui/app/components/CatalogDeleteModal.vue +44 -0
new file mode 100644
@@ -0,0 +1,44 @@
1+<script setup lang="ts">
2+/**
3+ * Delete confirmation for a catalog item or preset. `usedBy` lists the scopes referencing it (known up front);
4+ * `conflict` is set after the server answered 409, which turns the confirm button into "Delete anyway".
5+ */
6+const props = defineProps<{
7+ kind: 'item' | 'preset'
8+ name: string
9+ usedBy: string[]
10+ conflict: string | null
11+ deleting?: boolean
12+}>()
13+const open = defineModel<boolean>('open', { default: false })
14+const emit = defineEmits<{ confirm: [force: boolean] }>()
15+
16+const names = useScopeNames()
17+const effect = computed(() => props.kind === 'preset'
18+ ? 'The preset is removed from the catalog. Its items stay in the catalog.'
19+ : 'The item is removed from the catalog folder. Scopes where it was already written keep their files.')
20+</script>
21+
22+<template>
23+ <SkModal v-model:open="open" :title="`Delete ${name}`" width="sm">
24+ <div class="flex flex-col gap-3">
25+ <p class="m-0 text-sk-muted">{{ effect }}</p>
26+ <SkNote v-if="conflict || usedBy.length" tone="accent">
27+ <p class="m-0 font-medium">{{ conflict ? `This ${kind} is still used` : 'Used in' }}</p>
28+ <p class="mt-1 mb-0 text-xs">
29+ {{ kind === 'preset' ? 'These scopes enable it' : 'These scopes reference it' }}.
30+ If you delete it anyway, they report it as missing from the catalog.
31+ </p>
32+ <ul class="mt-1.5 mb-0 list-none p-0 font-mono text-xs">
33+ <li v-for="s in usedBy" :key="s">{{ scopeLabel(s, names) }}</li>
34+ </ul>
35+ </SkNote>
36+ </div>
37+ <template #footer>
38+ <div class="flex w-full justify-end gap-2">
39+ <SkButton label="Cancel" @click="open = false" />
40+ <SkButton variant="danger" :label="conflict ? 'Delete anyway' : 'Delete'" :loading="deleting" @click="emit('confirm', !!conflict)" />
41+ </div>
42+ </template>
43+ </SkModal>
44+</template>
new file mode 100644
@@ -0,0 +1,44 @@
1+<script setup lang="ts">
2+/**
3+ * Delete confirmation for a catalog item or preset. `usedBy` lists the scopes referencing it (known up front);
4+ * `conflict` is set after the server answered 409, which turns the confirm button into "Delete anyway".
5+ */
6+const props = defineProps<{
7+ kind: 'item' | 'preset'
8+ name: string
9+ usedBy: string[]
10+ conflict: string | null
11+ deleting?: boolean
12+}>()
13+const open = defineModel<boolean>('open', { default: false })
14+const emit = defineEmits<{ confirm: [force: boolean] }>()
15+
16+const names = useScopeNames()
17+const effect = computed(() => props.kind === 'preset'
18+ ? 'The preset is removed from the catalog. Its items stay in the catalog.'
19+ : 'The item is removed from the catalog folder. Scopes where it was already written keep their files.')
20+</script>
21+
22+<template>
23+ <SkModal v-model:open="open" :title="`Delete ${name}`" width="sm">
24+ <div class="flex flex-col gap-3">
25+ <p class="m-0 text-sk-muted">{{ effect }}</p>
26+ <SkNote v-if="conflict || usedBy.length" tone="accent">
27+ <p class="m-0 font-medium">{{ conflict ? `This ${kind} is still used` : 'Used in' }}</p>
28+ <p class="mt-1 mb-0 text-xs">
29+ {{ kind === 'preset' ? 'These scopes enable it' : 'These scopes reference it' }}.
30+ If you delete it anyway, they report it as missing from the catalog.
31+ </p>
32+ <ul class="mt-1.5 mb-0 list-none p-0 font-mono text-xs">
33+ <li v-for="s in usedBy" :key="s">{{ scopeLabel(s, names) }}</li>
34+ </ul>
35+ </SkNote>
36+ </div>
37+ <template #footer>
38+ <div class="flex w-full justify-end gap-2">
39+ <SkButton label="Cancel" @click="open = false" />
40+ <SkButton variant="danger" :label="conflict ? 'Delete anyway' : 'Delete'" :loading="deleting" @click="emit('confirm', !!conflict)" />
41+ </div>
42+ </template>
43+ </SkModal>
44+</template>
added packages/ui/app/components/CatalogField.vue +17 -0
new file mode 100644
@@ -0,0 +1,17 @@
1+<script setup lang="ts">
2+/** Form field wrapper for the catalog editors: label (500), optional hint, inline error in accent text. */
3+defineProps<{ label: string, hint?: string, error?: string | null, required?: boolean }>()
4+</script>
5+
6+<template>
7+ <div class="flex min-w-0 flex-col gap-1.5">
8+ <label class="flex flex-col gap-1.5">
9+ <span class="flex flex-wrap items-baseline gap-x-2">
10+ <span class="font-medium">{{ label }}<span v-if="required" class="ml-0.5 text-sk-accent-text" aria-hidden="true">*</span></span>
11+ <span v-if="hint" class="text-xs font-normal text-sk-faint">{{ hint }}</span>
12+ </span>
13+ <slot />
14+ </label>
15+ <span v-if="error" class="text-xs text-sk-accent-text">{{ error }}</span>
16+ </div>
17+</template>
new file mode 100644
@@ -0,0 +1,17 @@
1+<script setup lang="ts">
2+/** Form field wrapper for the catalog editors: label (500), optional hint, inline error in accent text. */
3+defineProps<{ label: string, hint?: string, error?: string | null, required?: boolean }>()
4+</script>
5+
6+<template>
7+ <div class="flex min-w-0 flex-col gap-1.5">
8+ <label class="flex flex-col gap-1.5">
9+ <span class="flex flex-wrap items-baseline gap-x-2">
10+ <span class="font-medium">{{ label }}<span v-if="required" class="ml-0.5 text-sk-accent-text" aria-hidden="true">*</span></span>
11+ <span v-if="hint" class="text-xs font-normal text-sk-faint">{{ hint }}</span>
12+ </span>
13+ <slot />
14+ </label>
15+ <span v-if="error" class="text-xs text-sk-accent-text">{{ error }}</span>
16+ </div>
17+</template>
deleted packages/ui/app/components/CatalogGitDialog.vue +0 -96
deleted file mode 100644
@@ -1,96 +0,0 @@
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>
deleted file mode 100644
@@ -1,96 +0,0 @@
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/CatalogHeader.vue +47 -0
new file mode 100644
@@ -0,0 +1,47 @@
1+<script setup lang="ts">
2+import type { CatalogItemType } from '@skilly/core'
3+
4+/**
5+ * Header shared by every catalog screen: title, "Your shared library", Items | Presets seg and the New button
6+ * (split button on Items: the main action creates the current type, the chevron lists every type).
7+ */
8+const props = defineProps<{ tab: 'items' | 'presets', newType?: CatalogItemType }>()
9+
10+const { exists, items, presets, git, data } = useCatalog()
11+
12+const seg = computed({
13+ get: () => props.tab,
14+ set: (v: 'items' | 'presets') => { if (v !== props.tab) navigateTo(v === 'items' ? '/catalog' : '/catalog/presets') }
15+})
16+const options = computed(() => [
17+ { value: 'items' as const, label: 'Items', count: items.value.length },
18+ { value: 'presets' as const, label: 'Presets', count: presets.value.length }
19+])
20+
21+const where = computed(() => git.value?.remote ?? data.value?.snapshot.root ?? '')
22+const type = computed(() => props.newType ?? 'skill')
23+const menu = computed(() => [CAT_ORDER.map(t => ({
24+ label: CAT_META[t].newLabel,
25+ icon: CAT_META[t].icon,
26+ to: `/catalog/new?type=${t}`
27+}))])
28+</script>
29+
30+<template>
31+ <SkPageHeader title="Catalog">
32+ <template #subtitle>
33+ Your shared library.
34+ <span v-if="where" class="font-mono text-xs" :title="where">{{ where }}</span>
35+ </template>
36+ <template v-if="exists" #actions>
37+ <SkSeg v-model="seg" :options="options" aria-label="Catalog section" />
38+ <SkButton v-if="tab === 'presets'" variant="primary" label="New preset" to="/catalog/presets/new" />
39+ <div v-else class="inline-flex">
40+ <SkButton variant="primary" :label="CAT_META[type].newLabel" :to="`/catalog/new?type=${type}`" class="rounded-r-none" />
41+ <UDropdownMenu :items="menu" :content="{ align: 'end' }">
42+ <SkButton variant="primary" icon="i-lucide-chevron-down" aria-label="New item of another type" class="rounded-l-none border-l border-sk-onink/25" />
43+ </UDropdownMenu>
44+ </div>
45+ </template>
46+ </SkPageHeader>
47+</template>
new file mode 100644
@@ -0,0 +1,47 @@
1+<script setup lang="ts">
2+import type { CatalogItemType } from '@skilly/core'
3+
4+/**
5+ * Header shared by every catalog screen: title, "Your shared library", Items | Presets seg and the New button
6+ * (split button on Items: the main action creates the current type, the chevron lists every type).
7+ */
8+const props = defineProps<{ tab: 'items' | 'presets', newType?: CatalogItemType }>()
9+
10+const { exists, items, presets, git, data } = useCatalog()
11+
12+const seg = computed({
13+ get: () => props.tab,
14+ set: (v: 'items' | 'presets') => { if (v !== props.tab) navigateTo(v === 'items' ? '/catalog' : '/catalog/presets') }
15+})
16+const options = computed(() => [
17+ { value: 'items' as const, label: 'Items', count: items.value.length },
18+ { value: 'presets' as const, label: 'Presets', count: presets.value.length }
19+])
20+
21+const where = computed(() => git.value?.remote ?? data.value?.snapshot.root ?? '')
22+const type = computed(() => props.newType ?? 'skill')
23+const menu = computed(() => [CAT_ORDER.map(t => ({
24+ label: CAT_META[t].newLabel,
25+ icon: CAT_META[t].icon,
26+ to: `/catalog/new?type=${t}`
27+}))])
28+</script>
29+
30+<template>
31+ <SkPageHeader title="Catalog">
32+ <template #subtitle>
33+ Your shared library.
34+ <span v-if="where" class="font-mono text-xs" :title="where">{{ where }}</span>
35+ </template>
36+ <template v-if="exists" #actions>
37+ <SkSeg v-model="seg" :options="options" aria-label="Catalog section" />
38+ <SkButton v-if="tab === 'presets'" variant="primary" label="New preset" to="/catalog/presets/new" />
39+ <div v-else class="inline-flex">
40+ <SkButton variant="primary" :label="CAT_META[type].newLabel" :to="`/catalog/new?type=${type}`" class="rounded-r-none" />
41+ <UDropdownMenu :items="menu" :content="{ align: 'end' }">
42+ <SkButton variant="primary" icon="i-lucide-chevron-down" aria-label="New item of another type" class="rounded-l-none border-l border-sk-onink/25" />
43+ </UDropdownMenu>
44+ </div>
45+ </template>
46+ </SkPageHeader>
47+</template>
modified packages/ui/app/components/CatalogItemEditor.vue +312 -202
@@ -1,12 +1,14 @@
11 <script setup lang="ts">
22 import type { CatalogItemDetail, CatalogItemType, HookTemplate, McpTemplate, SaveItemInput } from '@skilly/core'
33
4-/** Catalog item editor: Markdown (skill / rule / agent) or form + YAML (mcp / hook). `name` is undefined for a new item. */
4+/**
5+ * Detail panel of a catalog item: read view, then edit mode (Markdown split editor for skill / rule / subagent,
6+ * form + YAML for MCP servers and hooks). `name` is undefined for a new item (starts in edit mode).
7+ */
58 const props = defineProps<{ type: CatalogItemType, name?: string }>()
69
710 const { request } = useApi()
811 const catalog = useCatalog()
9-const scopeNames = useScopeNames()
1012 const toast = useToast()
1113
1214 const isNew = computed(() => !props.name)
@@ -16,6 +18,7 @@ const isMd = computed(() => meta.value.markdown)
1618 const loading = ref(!!props.name)
1719 const loadError = ref<string | null>(null)
1820 const detail = ref<CatalogItemDetail | null>(null)
21+const editing = ref(!props.name)
1922
2023 const form = reactive({ name: props.name ?? '', content: '', tags: [] as string[] })
2124 const mcp = ref<McpTemplate>({ name: '', transport: 'stdio', command: '', args: [] })
@@ -24,6 +27,7 @@ const extraFiles = ref<Record<string, string>>({})
2427 const deleteFiles = ref<string[]>([])
2528 const original = ref('')
2629 const view = ref<'edit' | 'split' | 'preview'>('split')
30+const formView = ref<'form' | 'yaml'>('form')
2731 const saving = ref(false)
2832 const saveError = ref<string | null>(null)
2933
@@ -36,6 +40,7 @@ const mainContent = computed(() => (isMd.value ? form.content : yaml.value))
3640
3741 const snapshot = () => JSON.stringify([form.name, mainContent.value, form.tags, extraFiles.value, deleteFiles.value])
3842 const dirty = computed(() => snapshot() !== original.value)
43+const guarded = computed(() => editing.value && dirty.value)
3944
4045 function seed(d: CatalogItemDetail | null) {
4146 detail.value = d
@@ -48,7 +53,7 @@ function seed(d: CatalogItemDetail | null) {
4853 if (props.type === 'mcp' && d.template) mcp.value = structuredClone(toRaw(d.template)) as McpTemplate
4954 if (props.type === 'hook' && d.template) hook.value = structuredClone(toRaw(d.template)) as HookTemplate
5055 } else if (isMd.value) {
51- form.content = newItemContent(props.type, form.name || 'new-item')
56+ form.content = newItemContent(props.type, form.name)
5257 }
5358 nextTick(() => { original.value = snapshot() })
5459 }
@@ -71,29 +76,49 @@ onMounted(() => {
7176 load()
7277 })
7378
74-/* ------------------------------------------------ Markdown fields */
79+/* ------------------------------------------------ Markdown fields (synced with the frontmatter) */
7580
81+const parsed = computed(() => splitFrontmatter(form.content))
7682 const description = computed({
7783 get: () => getFm(form.content, 'description'),
7884 set: (v: string) => { form.content = setFm(form.content, 'description', v) }
7985 })
86+const fmName = computed(() => getFm(form.content, 'name'))
87+
8088 function onName(v: string) {
89+ const before = form.name
8190 form.name = v
82- if (isMd.value) form.content = setFm(form.content, 'name', v)
91+ if (!isMd.value) return
92+ let c = setFm(form.content, 'name', v)
93+ // A new skill's title follows the name until the heading is edited by hand.
94+ if (props.type === 'skill' && isNew.value) c = retitle(c, before || PLACEHOLDER_TITLE, v || PLACEHOLDER_TITLE)
95+ form.content = c
96+}
97+// The name edited in the frontmatter wins over the field.
98+watch(fmName, (v) => { if (isMd.value && v && v !== form.name) form.name = v })
99+
100+const pathsText = ref('')
101+const normPaths = (v: string) => v.split('\n').map(x => x.trim()).filter(Boolean).join('\n')
102+watch(() => getFmList(form.content, 'paths').join('\n'), (v) => { if (v !== normPaths(pathsText.value)) pathsText.value = v }, { immediate: true })
103+function onPaths(v: string) {
104+ pathsText.value = v
105+ form.content = setFmList(form.content, 'paths', v.split('\n'))
83106 }
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'))
86107
87-const parsed = computed(() => splitFrontmatter(form.content))
88-const previewHtml = computed(() => renderMarkdown(parsed.value.body))
108+const bodyHtml = computed(() => renderMarkdown(parsed.value.body))
109+const rulePaths = computed(() => getFmList(form.content, 'paths'))
89110
90111 /* ------------------------------------------------ Validation */
91112
113+const renaming = computed(() => !isNew.value && form.name !== props.name)
114+const nameError = computed(() => {
115+ if (!form.name) return isNew.value && !dirty.value ? null : 'A name is required.'
116+ if (!NAME_RE.test(form.name)) return 'Use kebab-case: lowercase letters, digits, "." "_" "-", starting with a letter or digit.'
117+ if ((isNew.value || renaming.value) && catalog.findItem(props.type, form.name)) return `A ${meta.value.singular.toLowerCase()} named "${form.name}" already exists.`
118+ return null
119+})
92120 const problems = computed(() => {
93121 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.`)
97122 if (isMd.value) {
98123 if (!parsed.value.frontmatter) p.push('The file has no frontmatter block (--- ... ---).')
99124 if (!description.value.trim()) p.push('The frontmatter needs a description.')
@@ -105,17 +130,17 @@ const problems = computed(() => {
105130 }
106131 return p
107132 })
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)
133+const canSave = computed(() => dirty.value && !!form.name && !nameError.value && !problems.value.length)
134+
135+/* ------------------------------------------------ Save / cancel */
111136
112-/* ------------------------------------------------ Save */
137+const usedBy = computed(() => (props.name ? catalog.usedBy(props.type, props.name) : []))
113138
114139 async function save() {
115- if (problems.value.length) return
140+ if (!canSave.value) return
116141 saving.value = true
117142 saveError.value = null
118- const content = isMd.value && nameMismatch.value ? setFm(form.content, 'name', form.name) : mainContent.value
143+ const content = isMd.value && fmName.value !== form.name ? setFm(form.content, 'name', form.name) : mainContent.value
119144 const body: SaveItemInput = {
120145 type: props.type,
121146 name: form.name,
@@ -126,7 +151,13 @@ async function save() {
126151 }
127152 try {
128153 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' })
154+ const n = usedBy.value.length
155+ toast.add({
156+ title: isNew.value ? 'Created' : 'Saved to catalog',
157+ description: !isNew.value && n ? `${form.name} · ${n} scope${n > 1 ? 's' : ''} will show an update` : form.name,
158+ icon: 'i-lucide-check',
159+ color: 'success'
160+ })
130161 await catalog.refresh()
131162 if (isNew.value || renaming.value) {
132163 original.value = snapshot()
@@ -134,6 +165,7 @@ async function save() {
134165 } else {
135166 form.content = content
136167 await load()
168+ editing.value = false
137169 }
138170 } catch (e) {
139171 saveError.value = (e as Error).message
@@ -142,14 +174,30 @@ async function save() {
142174 }
143175 }
144176
145-onBeforeRouteLeave(() => !dirty.value || window.confirm('Discard unsaved changes?'))
177+function cancel() {
178+ if (dirty.value && !window.confirm('Discard unsaved changes?')) return
179+ if (isNew.value) {
180+ original.value = snapshot()
181+ navigateTo(`/catalog?type=${props.type}`)
182+ return
183+ }
184+ saveError.value = null
185+ seed(detail.value)
186+ editing.value = false
187+}
188+
189+onBeforeRouteLeave(() => !guarded.value || window.confirm('Discard unsaved changes?'))
190+function beforeUnload(e: BeforeUnloadEvent) {
191+ if (guarded.value) e.preventDefault()
192+}
193+onMounted(() => window.addEventListener('beforeunload', beforeUnload))
194+onBeforeUnmount(() => window.removeEventListener('beforeunload', beforeUnload))
146195
147196 /* ------------------------------------------------ Delete */
148197
149198 const delOpen = ref(false)
150199 const deleting = ref(false)
151200 const delConflict = ref<string | null>(null)
152-const usedBy = computed(() => (props.name ? catalog.usedBy(props.type, props.name) : []))
153201
154202 async function doDelete(force = false) {
155203 deleting.value = true
@@ -200,206 +248,268 @@ function toggleDelete(p: string) {
200248 deleteFiles.value = deleteFiles.value.includes(p) ? deleteFiles.value.filter(x => x !== p) : [...deleteFiles.value, p]
201249 }
202250
203-const VIEWS = [
204- { label: 'Edit', value: 'edit' },
205- { label: 'Split', value: 'split' },
206- { label: 'Preview', value: 'preview' }
251+/* ------------------------------------------------ View helpers */
252+
253+const EDIT_VIEWS = [
254+ { label: 'Edit', value: 'edit' as const },
255+ { label: 'Split', value: 'split' as const },
256+ { label: 'Preview', value: 'preview' as const }
257+]
258+const FORM_VIEWS = [
259+ { label: 'Form', value: 'form' as const },
260+ { label: 'YAML', value: 'yaml' as const }
207261 ]
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'
262+
263+const originLabel = computed(() => {
264+ const o = detail.value?.item.origin
265+ if (!o || o.kind === 'created') return 'Created in skilly'
266+ if (o.kind === 'import') return 'Imported'
267+ return 'Marketplace'
268+})
269+const tpl = computed(() => detail.value?.template)
270+const envNames = computed(() => {
271+ if (props.type !== 'mcp') return []
272+ const m = detail.value?.template as McpTemplate | undefined
273+ if (!m) return []
274+ const all = { ...(m.env ?? {}), ...(m.headers ?? {}) }
275+ const out = Object.values(all).map(v => v.fromEnv).filter((v): v is string => !!v)
276+ if (m.auth?.bearerFromEnv) out.push(m.auth.bearerFromEnv)
277+ return out
278+})
279+const hookAgents = computed(() => {
280+ const a = (detail.value?.template as HookTemplate | undefined)?.agents
281+ return a?.length ? a.map(x => AGENT_LABELS[x]).join(', ') : 'Claude Code, Codex'
282+})
283+const fileLabel = computed(() => {
284+ if (props.type === 'skill') return form.name ? `skills/${form.name}/SKILL.md` : 'SKILL.md'
285+ const dir = props.type === 'rule' ? 'rules' : 'agents'
286+ return form.name ? `${dir}/${form.name}.md` : ''
287+})
288+const yamlLabel = computed(() => (form.name ? `${props.type === 'mcp' ? 'mcp' : 'hooks'}/${form.name}.yaml` : 'YAML'))
289+const title = computed(() => form.name || (isNew.value ? meta.value.newLabel : ''))
290+
291+const TA = 'block w-full resize-none border-0 bg-sk-bg px-5 py-[18px] font-mono text-[12.5px] leading-[1.65] text-sk-text outline-none placeholder:text-sk-faint'
209292 </script>
210293
211294 <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>
295+ <SkPanel class="flex min-w-0 flex-col">
296+ <div v-if="loading" class="flex flex-col gap-3 p-[22px]">
297+ <USkeleton class="h-6 w-1/3" />
298+ <USkeleton class="h-4 w-2/3" />
299+ <USkeleton class="h-64 w-full" />
300+ </div>
301+ <div v-else-if="loadError" class="p-[22px]">
302+ <SkNote tone="accent">
303+ <p class="m-0 font-medium">Could not load this item</p>
304+ <p class="mt-1 mb-0 text-xs">{{ loadError }}</p>
305+ <template #aside><SkButton size="sm" label="Retry" @click="load" /></template>
306+ </SkNote>
307+ </div>
308+
309+ <template v-else>
310+ <!-- Header: title, actions, fields or meta -->
311+ <div class="flex flex-col gap-3 border-b border-sk-line2 px-[22px] py-5">
312+ <div class="flex items-start justify-between gap-3">
313+ <div class="flex min-w-0 flex-col gap-1">
314+ <div class="flex items-baseline gap-2.5">
315+ <h2 class="m-0 truncate text-lg font-semibold tracking-[-0.01em]" :class="!form.name && 'text-sk-faint'">{{ title }}</h2>
316+ <SkTypeTag :type="type" />
317+ <span v-if="editing && dirty" class="font-mono text-[11px] text-sk-accent-text">unsaved</span>
318+ </div>
319+ <p v-if="!editing && detail?.item.description" class="m-0 text-pretty text-sk-muted">{{ detail.item.description }}</p>
320+ </div>
321+ <div class="flex shrink-0 gap-1.5">
322+ <template v-if="!editing">
323+ <SkButton size="sm" label="Edit" @click="editing = true" />
324+ </template>
325+ <template v-else>
326+ <SkButton v-if="!isNew" size="sm" variant="danger" label="Delete" @click="askDelete" />
327+ <SkButton size="sm" label="Cancel" @click="cancel" />
328+ <SkButton size="sm" variant="primary" label="Save" :loading="saving" :disabled="!canSave" @click="save" />
329+ </template>
330+ </div>
331+ </div>
229332
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>
333+ <!-- Edit: fields -->
334+ <template v-if="editing">
335+ <SkNote v-if="saveError" tone="accent">
336+ <p class="m-0 font-medium">Save failed</p>
337+ <p class="mt-1 mb-0 text-xs whitespace-pre-wrap">{{ saveError }}</p>
338+ </SkNote>
339+
340+ <div class="grid gap-x-4 gap-y-3.5" :class="isMd ? 'md:grid-cols-2' : ''">
341+ <CatalogField :label="renaming ? 'Name (renaming)' : 'Name'" required :error="nameError">
342+ <UInput
343+ :model-value="form.name"
344+ class="w-full"
345+ placeholder="kebab-case-name"
346+ :color="nameError ? 'error' : undefined"
347+ :highlight="!!nameError"
348+ :ui="{ base: 'font-mono text-xs' }"
349+ @update:model-value="onName"
350+ />
351+ </CatalogField>
352+ <CatalogField v-if="isMd" label="Description" required :hint="type === 'skill' ? 'Tells the agent when to use the skill.' : undefined">
353+ <UInput v-model="description" class="w-full" :placeholder="type === 'rule' ? 'What the rule enforces' : 'When to use this'" />
354+ </CatalogField>
355+ <CatalogField v-if="type === 'rule'" label="Applies to" hint="Globs, one per line. Empty: always loaded." class="md:col-span-2">
356+ <UTextarea
357+ :model-value="pathsText"
358+ class="w-full"
359+ :rows="3"
360+ placeholder="src/**/*.ts&#10;app/**/*.vue"
361+ :ui="{ base: 'font-mono text-xs' }"
362+ aria-label="paths globs"
363+ @update:model-value="onPaths"
364+ />
365+ </CatalogField>
366+ <CatalogField label="Tags" hint="Press Enter to add" class="md:col-span-2">
367+ <UInputTags v-model="form.tags" add-on-blur placeholder="Add a tag" class="w-full" />
368+ </CatalogField>
249369 </div>
250370
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." />
371+ <SkNote v-if="renaming">
372+ Renaming <span class="font-mono text-xs">{{ name }}</span> to <span class="font-mono text-xs">{{ form.name }}</span>.
373+ Scopes that reference the old name report it as missing until they are updated.
374+ </SkNote>
375+ <SkNote v-if="problems.length && (dirty || !isNew)" tone="accent">
376+ <p class="m-0 font-medium">Fix before saving</p>
377+ <ul class="mt-1 mb-0 list-disc pl-4 text-xs">
378+ <li v-for="p in problems" :key="p">{{ p }}</li>
379+ </ul>
380+ </SkNote>
381+ </template>
252382
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- />
383+ <!-- View: meta grid -->
384+ <SkMetaGrid v-else label-width="96px" class="text-xs">
385+ <SkMeta label="Tags">
386+ <span v-if="detail?.item.tags.length" class="flex flex-wrap gap-1"><SkTag v-for="t in detail.item.tags" :key="t">{{ t }}</SkTag></span>
387+ <span v-else class="text-sk-faint">No tags</span>
388+ </SkMeta>
389+ <SkMeta v-if="type === 'rule'" label="Applies to" :mono="!!rulePaths.length">
390+ <template v-if="rulePaths.length"><div v-for="p in rulePaths" :key="p">{{ p }}</div></template>
391+ <span v-else class="font-sans text-sk-muted">Every file (always loaded)</span>
392+ </SkMeta>
393+ <SkMeta v-if="type === 'mcp' && tpl" label="Transport"><span class="text-sk-muted">{{ (tpl as McpTemplate).transport }}</span></SkMeta>
394+ <SkMeta v-if="type === 'hook' && tpl" label="Event"><span class="font-mono text-sk-muted">{{ (tpl as HookTemplate).event }}<template v-if="(tpl as HookTemplate).matcher"> · {{ (tpl as HookTemplate).matcher }}</template></span></SkMeta>
395+ <SkMeta label="Renders for"><span class="text-sk-muted">{{ type === 'hook' ? hookAgents : 'Claude Code, Codex' }}</span></SkMeta>
396+ <SkMeta label="Source">
397+ <span class="text-sk-muted">{{ originLabel }}</span>
398+ <span v-if="detail?.item.origin?.source" class="ml-1.5 font-mono text-[11px] text-sk-faint">{{ detail.item.origin.source }}</span>
399+ </SkMeta>
400+ <SkMeta v-if="envNames.length" label="Env vars" mono><span class="text-sk-muted">{{ envNames.join(', ') }}</span></SkMeta>
401+ <SkMeta label="Used in"><CatalogUsedBy :scopes="usedBy" /></SkMeta>
402+ <SkMeta v-if="detail" label="Updated"><span class="text-sk-muted">{{ formatDate(detail.item.updatedAt) }}</span></SkMeta>
403+ <SkMeta v-if="detail" label="Path" mono><span class="break-all text-sk-faint" :title="detail.item.path">{{ detail.item.path }}</span></SkMeta>
404+ </SkMetaGrid>
405+ </div>
268406
269- <!-- Markdown editor -->
407+ <!-- View: body -->
408+ <template v-if="!editing">
409+ <div class="flex flex-col gap-3 px-[22px] py-5">
270410 <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>
411+ <MarkdownBody v-if="parsed.body.trim()" :html="bodyHtml" />
412+ <p v-else class="m-0 text-sk-faint">This item has no content yet.</p>
292413 </template>
293-
294- <!-- MCP / hook form -->
295414 <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>
415+ <SkSectionLabel>{{ yamlLabel }}</SkSectionLabel>
416+ <SkCode :text="yaml" />
417+ <p v-if="envNames.length" class="m-0 text-pretty text-sk-muted">
418+ The value of {{ envNames.join(', ') }} is read from your environment. It is never stored in the catalog.
419+ </p>
420+ <p v-if="type === 'hook'" class="m-0 text-xs text-sk-faint">skilly never runs the command: the agent does.</p>
305421 </template>
306- </div>
307422
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" add-on-blur 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>
423+ <div v-if="type === 'skill' && existingFiles.length" class="flex flex-col gap-2 pt-1">
424+ <SkSectionLabel :count="existingFiles.length">Extra files</SkSectionLabel>
425+ <ul class="m-0 flex list-none flex-col p-0">
426+ <li v-for="f in existingFiles" :key="f.path" class="flex items-center gap-3 border-t border-sk-line2 py-1.5 first:border-t-0">
427+ <button type="button" class="min-w-0 flex-1 cursor-pointer truncate border-0 bg-transparent p-0 text-left font-mono text-xs text-sk-text hover:text-sk-accent-text" @click="viewFile = f.path">{{ f.path }}</button>
428+ <span class="font-mono text-[11px] text-sk-faint">{{ formatBytes(f.sizeBytes) }}</span>
323429 </li>
324430 </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>
431+ </div>
432+ </div>
433+ </template>
354434
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>
435+ <!-- Edit: Markdown split editor -->
436+ <template v-else-if="isMd">
437+ <div class="flex items-center justify-between gap-3 px-[22px] py-2.5">
438+ <span class="truncate font-mono text-[11px] text-sk-faint">{{ fileLabel || 'content' }}</span>
439+ <SkSeg v-model="view" :options="EDIT_VIEWS" size="xs" aria-label="Editor view" />
440+ </div>
441+ <div class="grid min-h-[420px] border-t border-sk-line2" :class="view === 'split' ? 'md:grid-cols-2' : ''">
442+ <textarea
443+ v-if="view !== 'preview'"
444+ v-model="form.content"
445+ :class="[TA, view === 'split' && 'md:border-r md:border-sk-line2']"
446+ class="h-[520px] rounded-bl-[20px]"
447+ spellcheck="false"
448+ aria-label="Markdown content"
449+ />
450+ <div v-if="view !== 'edit'" class="flex h-[520px] flex-col gap-2.5 overflow-auto px-5 py-[18px]">
451+ <SkSectionLabel>Preview</SkSectionLabel>
452+ <MarkdownBody :html="bodyHtml" />
453+ </div>
454+ </div>
364455
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">Still referenced by the configuration of these scopes. If you delete it anyway, they will report it as missing from the catalog.</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>
456+ <!-- Skill files -->
457+ <div v-if="type === 'skill'" class="flex flex-col gap-3 border-t border-sk-line2 px-[22px] py-5">
458+ <SkSectionLabel :count="existingFiles.length + Object.keys(extraFiles).length">Extra files</SkSectionLabel>
459+ <ul v-if="existingFiles.length || Object.keys(extraFiles).length" class="m-0 flex list-none flex-col p-0">
460+ <li v-for="f in existingFiles" :key="f.path" class="flex items-center gap-3 border-t border-sk-line2 py-1.5 first:border-t-0" :class="deleteFiles.includes(f.path) && 'opacity-55'">
461+ <button type="button" class="min-w-0 flex-1 cursor-pointer truncate border-0 bg-transparent p-0 text-left font-mono text-xs text-sk-text hover:text-sk-accent-text" :class="deleteFiles.includes(f.path) && 'line-through'" @click="viewFile = f.path">{{ f.path }}</button>
462+ <span class="font-mono text-[11px] text-sk-faint">{{ formatBytes(f.sizeBytes) }}</span>
463+ <SkButton size="xs" variant="ghost" :label="deleteFiles.includes(f.path) ? 'Keep' : 'Delete'" @click="toggleDelete(f.path)" />
464+ </li>
465+ <li v-for="(_c, p) in extraFiles" :key="p" class="flex items-center gap-3 border-t border-sk-line2 py-1.5 first:border-t-0">
466+ <span class="min-w-0 flex-1 truncate font-mono text-xs">{{ p }}</span>
467+ <SkTag mono>new</SkTag>
468+ <SkButton size="xs" variant="ghost" label="Discard" @click="dropNew(p as string)" />
469+ </li>
470+ </ul>
471+ <p v-else class="m-0 text-sk-faint">SKILL.md only.</p>
472+
473+ <div class="flex flex-col gap-2 rounded-[10px] border border-dashed border-sk-line p-3">
474+ <UInput v-model="newFile.path" placeholder="references/notes.md" class="w-full" :ui="{ base: 'font-mono text-xs' }" aria-label="New file path" />
475+ <span v-if="newFileError" class="text-xs text-sk-accent-text">{{ newFileError }}</span>
476+ <UTextarea v-model="newFile.content" :rows="4" placeholder="Text content" class="w-full" :ui="{ base: 'font-mono text-xs' }" aria-label="New file content" />
477+ <div><SkButton size="sm" icon="i-lucide-plus" label="Add file" :disabled="!newFile.path.trim() || !!newFileError" @click="addFile" /></div>
394478 </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)" />
479+ </div>
480+ </template>
481+
482+ <!-- Edit: MCP / hook form + YAML -->
483+ <template v-else>
484+ <div class="flex items-center justify-between gap-3 px-[22px] py-2.5">
485+ <span class="truncate font-mono text-[11px] text-sk-faint">{{ yamlLabel }}</span>
486+ <SkSeg v-model="formView" :options="FORM_VIEWS" size="xs" aria-label="Editor view" />
487+ </div>
488+ <div class="border-t border-sk-line2 px-[22px] py-5">
489+ <template v-if="formView === 'form'">
490+ <McpForm v-if="type === 'mcp'" v-model="mcp" :key="detail?.item.hash ?? 'new'" />
491+ <HookForm v-else v-model="hook" />
492+ </template>
493+ <div v-else class="flex flex-col gap-2">
494+ <SkCode :text="yaml" max-height="520px" />
495+ <p class="m-0 text-xs text-sk-faint">Read-only: generated from the form.</p>
400496 </div>
401- </template>
402- </UModal>
497+ </div>
498+ </template>
403499 </template>
404- </UDashboardPanel>
500+
501+ <SkDrawer :open="!!viewFile" :title="viewFile ?? ''" width="lg" @update:open="(v: boolean) => { if (!v) viewFile = null }">
502+ <MarkdownView v-if="viewFile && detail" :path="joinPath(detail.item.path, viewFile)" />
503+ </SkDrawer>
504+
505+ <CatalogDeleteModal
506+ v-model:open="delOpen"
507+ kind="item"
508+ :name="name ?? ''"
509+ :used-by="usedBy"
510+ :conflict="delConflict"
511+ :deleting="deleting"
512+ @confirm="doDelete"
513+ />
514+ </SkPanel>
405515 </template>
@@ -1,12 +1,14 @@
1 <script setup lang="ts">1 <script setup lang="ts">
2 import type { CatalogItemDetail, CatalogItemType, HookTemplate, McpTemplate, SaveItemInput } from '@skilly/core'2 import type { CatalogItemDetail, CatalogItemType, HookTemplate, McpTemplate, SaveItemInput } from '@skilly/core'
3 3
4-/** Catalog item editor: Markdown (skill / rule / agent) or form + YAML (mcp / hook). `name` is undefined for a new item. */4+/**
5+ * Detail panel of a catalog item: read view, then edit mode (Markdown split editor for skill / rule / subagent,
6+ * form + YAML for MCP servers and hooks). `name` is undefined for a new item (starts in edit mode).
7+ */
5 const props = defineProps<{ type: CatalogItemType, name?: string }>()8 const props = defineProps<{ type: CatalogItemType, name?: string }>()
6 9
7 const { request } = useApi()10 const { request } = useApi()
8 const catalog = useCatalog()11 const catalog = useCatalog()
9-const scopeNames = useScopeNames()
10 const toast = useToast()12 const toast = useToast()
11 13
12 const isNew = computed(() => !props.name)14 const isNew = computed(() => !props.name)
@@ -16,6 +18,7 @@ const isMd = computed(() => meta.value.markdown)
16 const loading = ref(!!props.name)18 const loading = ref(!!props.name)
17 const loadError = ref<string | null>(null)19 const loadError = ref<string | null>(null)
18 const detail = ref<CatalogItemDetail | null>(null)20 const detail = ref<CatalogItemDetail | null>(null)
21+const editing = ref(!props.name)
19 22
20 const form = reactive({ name: props.name ?? '', content: '', tags: [] as string[] })23 const form = reactive({ name: props.name ?? '', content: '', tags: [] as string[] })
21 const mcp = ref<McpTemplate>({ name: '', transport: 'stdio', command: '', args: [] })24 const mcp = ref<McpTemplate>({ name: '', transport: 'stdio', command: '', args: [] })
@@ -24,6 +27,7 @@ const extraFiles = ref<Record<string, string>>({})
24 const deleteFiles = ref<string[]>([])27 const deleteFiles = ref<string[]>([])
25 const original = ref('')28 const original = ref('')
26 const view = ref<'edit' | 'split' | 'preview'>('split')29 const view = ref<'edit' | 'split' | 'preview'>('split')
30+const formView = ref<'form' | 'yaml'>('form')
27 const saving = ref(false)31 const saving = ref(false)
28 const saveError = ref<string | null>(null)32 const saveError = ref<string | null>(null)
29 33
@@ -36,6 +40,7 @@ const mainContent = computed(() => (isMd.value ? form.content : yaml.value))
36 40
37 const snapshot = () => JSON.stringify([form.name, mainContent.value, form.tags, extraFiles.value, deleteFiles.value])41 const snapshot = () => JSON.stringify([form.name, mainContent.value, form.tags, extraFiles.value, deleteFiles.value])
38 const dirty = computed(() => snapshot() !== original.value)42 const dirty = computed(() => snapshot() !== original.value)
43+const guarded = computed(() => editing.value && dirty.value)
39 44
40 function seed(d: CatalogItemDetail | null) {45 function seed(d: CatalogItemDetail | null) {
41 detail.value = d46 detail.value = d
@@ -48,7 +53,7 @@ function seed(d: CatalogItemDetail | null) {
48 if (props.type === 'mcp' && d.template) mcp.value = structuredClone(toRaw(d.template)) as McpTemplate53 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 HookTemplate54 if (props.type === 'hook' && d.template) hook.value = structuredClone(toRaw(d.template)) as HookTemplate
50 } else if (isMd.value) {55 } else if (isMd.value) {
51- form.content = newItemContent(props.type, form.name || 'new-item')56+ form.content = newItemContent(props.type, form.name)
52 }57 }
53 nextTick(() => { original.value = snapshot() })58 nextTick(() => { original.value = snapshot() })
54 }59 }
@@ -71,29 +76,49 @@ onMounted(() => {
71 load()76 load()
72 })77 })
73 78
74-/* ------------------------------------------------ Markdown fields */79+/* ------------------------------------------------ Markdown fields (synced with the frontmatter) */
75 80
81+const parsed = computed(() => splitFrontmatter(form.content))
76 const description = computed({82 const description = computed({
77 get: () => getFm(form.content, 'description'),83 get: () => getFm(form.content, 'description'),
78 set: (v: string) => { form.content = setFm(form.content, 'description', v) }84 set: (v: string) => { form.content = setFm(form.content, 'description', v) }
79 })85 })
86+const fmName = computed(() => getFm(form.content, 'name'))
87+
80 function onName(v: string) {88 function onName(v: string) {
89+ const before = form.name
81 form.name = v90 form.name = v
82- if (isMd.value) form.content = setFm(form.content, 'name', v)91+ if (!isMd.value) return
92+ let c = setFm(form.content, 'name', v)
93+ // A new skill's title follows the name until the heading is edited by hand.
94+ if (props.type === 'skill' && isNew.value) c = retitle(c, before || PLACEHOLDER_TITLE, v || PLACEHOLDER_TITLE)
95+ form.content = c
96+}
97+// The name edited in the frontmatter wins over the field.
98+watch(fmName, (v) => { if (isMd.value && v && v !== form.name) form.name = v })
99+
100+const pathsText = ref('')
101+const normPaths = (v: string) => v.split('\n').map(x => x.trim()).filter(Boolean).join('\n')
102+watch(() => getFmList(form.content, 'paths').join('\n'), (v) => { if (v !== normPaths(pathsText.value)) pathsText.value = v }, { immediate: true })
103+function onPaths(v: string) {
104+ pathsText.value = v
105+ form.content = setFmList(form.content, 'paths', v.split('\n'))
83 }106 }
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 107
87-const parsed = computed(() => splitFrontmatter(form.content))108+const bodyHtml = computed(() => renderMarkdown(parsed.value.body))
88-const previewHtml = computed(() => renderMarkdown(parsed.value.body))109+const rulePaths = computed(() => getFmList(form.content, 'paths'))
89 110
90 /* ------------------------------------------------ Validation */111 /* ------------------------------------------------ Validation */
91 112
113+const renaming = computed(() => !isNew.value && form.name !== props.name)
114+const nameError = computed(() => {
115+ if (!form.name) return isNew.value && !dirty.value ? null : 'A name is required.'
116+ if (!NAME_RE.test(form.name)) return 'Use kebab-case: lowercase letters, digits, "." "_" "-", starting with a letter or digit.'
117+ if ((isNew.value || renaming.value) && catalog.findItem(props.type, form.name)) return `A ${meta.value.singular.toLowerCase()} named "${form.name}" already exists.`
118+ return null
119+})
92 const problems = computed(() => {120 const problems = computed(() => {
93 const p: string[] = []121 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) {122 if (isMd.value) {
98 if (!parsed.value.frontmatter) p.push('The file has no frontmatter block (--- ... ---).')123 if (!parsed.value.frontmatter) p.push('The file has no frontmatter block (--- ... ---).')
99 if (!description.value.trim()) p.push('The frontmatter needs a description.')124 if (!description.value.trim()) p.push('The frontmatter needs a description.')
@@ -105,17 +130,17 @@ const problems = computed(() => {
105 }130 }
106 return p131 return p
107 })132 })
108-const nameMismatch = computed(() => isMd.value && !!parsed.value.frontmatter && fmName.value !== form.name && !!form.name)133+const canSave = computed(() => dirty.value && !!form.name && !nameError.value && !problems.value.length)
109-const mismatchTitle = computed(() => `Frontmatter name is ${fmName.value ? '"' + fmName.value + '"' : 'missing'}`)134+
110-const renaming = computed(() => !isNew.value && form.name !== props.name)135+/* ------------------------------------------------ Save / cancel */
111 136
112-/* ------------------------------------------------ Save */137+const usedBy = computed(() => (props.name ? catalog.usedBy(props.type, props.name) : []))
113 138
114 async function save() {139 async function save() {
115- if (problems.value.length) return140+ if (!canSave.value) return
116 saving.value = true141 saving.value = true
117 saveError.value = null142 saveError.value = null
118- const content = isMd.value && nameMismatch.value ? setFm(form.content, 'name', form.name) : mainContent.value143+ const content = isMd.value && fmName.value !== form.name ? setFm(form.content, 'name', form.name) : mainContent.value
119 const body: SaveItemInput = {144 const body: SaveItemInput = {
120 type: props.type,145 type: props.type,
121 name: form.name,146 name: form.name,
@@ -126,7 +151,13 @@ async function save() {
126 }151 }
127 try {152 try {
128 await request(`/api/catalog/items/${props.type}/${encodeURIComponent(form.name)}`, { method: 'PUT', body, silent: true })153 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' })154+ const n = usedBy.value.length
155+ toast.add({
156+ title: isNew.value ? 'Created' : 'Saved to catalog',
157+ description: !isNew.value && n ? `${form.name} · ${n} scope${n > 1 ? 's' : ''} will show an update` : form.name,
158+ icon: 'i-lucide-check',
159+ color: 'success'
160+ })
130 await catalog.refresh()161 await catalog.refresh()
131 if (isNew.value || renaming.value) {162 if (isNew.value || renaming.value) {
132 original.value = snapshot()163 original.value = snapshot()
@@ -134,6 +165,7 @@ async function save() {
134 } else {165 } else {
135 form.content = content166 form.content = content
136 await load()167 await load()
168+ editing.value = false
137 }169 }
138 } catch (e) {170 } catch (e) {
139 saveError.value = (e as Error).message171 saveError.value = (e as Error).message
@@ -142,14 +174,30 @@ async function save() {
142 }174 }
143 }175 }
144 176
145-onBeforeRouteLeave(() => !dirty.value || window.confirm('Discard unsaved changes?'))177+function cancel() {
178+ if (dirty.value && !window.confirm('Discard unsaved changes?')) return
179+ if (isNew.value) {
180+ original.value = snapshot()
181+ navigateTo(`/catalog?type=${props.type}`)
182+ return
183+ }
184+ saveError.value = null
185+ seed(detail.value)
186+ editing.value = false
187+}
188+
189+onBeforeRouteLeave(() => !guarded.value || window.confirm('Discard unsaved changes?'))
190+function beforeUnload(e: BeforeUnloadEvent) {
191+ if (guarded.value) e.preventDefault()
192+}
193+onMounted(() => window.addEventListener('beforeunload', beforeUnload))
194+onBeforeUnmount(() => window.removeEventListener('beforeunload', beforeUnload))
146 195
147 /* ------------------------------------------------ Delete */196 /* ------------------------------------------------ Delete */
148 197
149 const delOpen = ref(false)198 const delOpen = ref(false)
150 const deleting = ref(false)199 const deleting = ref(false)
151 const delConflict = ref<string | null>(null)200 const delConflict = ref<string | null>(null)
152-const usedBy = computed(() => (props.name ? catalog.usedBy(props.type, props.name) : []))
153 201
154 async function doDelete(force = false) {202 async function doDelete(force = false) {
155 deleting.value = true203 deleting.value = true
@@ -200,206 +248,268 @@ function toggleDelete(p: string) {
200 deleteFiles.value = deleteFiles.value.includes(p) ? deleteFiles.value.filter(x => x !== p) : [...deleteFiles.value, p]248 deleteFiles.value = deleteFiles.value.includes(p) ? deleteFiles.value.filter(x => x !== p) : [...deleteFiles.value, p]
201 }249 }
202 250
203-const VIEWS = [251+/* ------------------------------------------------ View helpers */
204- { label: 'Edit', value: 'edit' },252+
205- { label: 'Split', value: 'split' },253+const EDIT_VIEWS = [
206- { label: 'Preview', value: 'preview' }254+ { label: 'Edit', value: 'edit' as const },
255+ { label: 'Split', value: 'split' as const },
256+ { label: 'Preview', value: 'preview' as const }
257+]
258+const FORM_VIEWS = [
259+ { label: 'Form', value: 'form' as const },
260+ { label: 'YAML', value: 'yaml' as const }
207 ]261 ]
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'262+
263+const originLabel = computed(() => {
264+ const o = detail.value?.item.origin
265+ if (!o || o.kind === 'created') return 'Created in skilly'
266+ if (o.kind === 'import') return 'Imported'
267+ return 'Marketplace'
268+})
269+const tpl = computed(() => detail.value?.template)
270+const envNames = computed(() => {
271+ if (props.type !== 'mcp') return []
272+ const m = detail.value?.template as McpTemplate | undefined
273+ if (!m) return []
274+ const all = { ...(m.env ?? {}), ...(m.headers ?? {}) }
275+ const out = Object.values(all).map(v => v.fromEnv).filter((v): v is string => !!v)
276+ if (m.auth?.bearerFromEnv) out.push(m.auth.bearerFromEnv)
277+ return out
278+})
279+const hookAgents = computed(() => {
280+ const a = (detail.value?.template as HookTemplate | undefined)?.agents
281+ return a?.length ? a.map(x => AGENT_LABELS[x]).join(', ') : 'Claude Code, Codex'
282+})
283+const fileLabel = computed(() => {
284+ if (props.type === 'skill') return form.name ? `skills/${form.name}/SKILL.md` : 'SKILL.md'
285+ const dir = props.type === 'rule' ? 'rules' : 'agents'
286+ return form.name ? `${dir}/${form.name}.md` : ''
287+})
288+const yamlLabel = computed(() => (form.name ? `${props.type === 'mcp' ? 'mcp' : 'hooks'}/${form.name}.yaml` : 'YAML'))
289+const title = computed(() => form.name || (isNew.value ? meta.value.newLabel : ''))
290+
291+const TA = 'block w-full resize-none border-0 bg-sk-bg px-5 py-[18px] font-mono text-[12.5px] leading-[1.65] text-sk-text outline-none placeholder:text-sk-faint'
209 </script>292 </script>
210 293
211 <template>294 <template>
212- <UDashboardPanel :id="`catalog-item-${type}`">295+ <SkPanel class="flex min-w-0 flex-col">
213- <template #header>296+ <div v-if="loading" class="flex flex-col gap-3 p-[22px]">
214- <UDashboardNavbar icon="i-lucide-library">297+ <USkeleton class="h-6 w-1/3" />
215- <template #title>298+ <USkeleton class="h-4 w-2/3" />
216- <NuxtLink :to="`/catalog?type=${type}`" class="text-muted hover:text-default">Catalog</NuxtLink>299+ <USkeleton class="h-64 w-full" />
217- <UIcon name="i-lucide-chevron-right" class="size-4 text-dimmed" />300+ </div>
218- <span class="text-muted">{{ meta.label }}</span>301+ <div v-else-if="loadError" class="p-[22px]">
219- <UIcon name="i-lucide-chevron-right" class="size-4 text-dimmed" />302+ <SkNote tone="accent">
220- <span class="truncate">{{ isNew ? 'New' : name }}</span>303+ <p class="m-0 font-medium">Could not load this item</p>
221- </template>304+ <p class="mt-1 mb-0 text-xs">{{ loadError }}</p>
222- <template #right>305+ <template #aside><SkButton size="sm" label="Retry" @click="load" /></template>
223- <UBadge v-if="dirty" label="Unsaved" color="warning" variant="subtle" />306+ </SkNote>
224- <UButton v-if="!isNew" label="Delete" icon="i-lucide-trash-2" color="error" variant="soft" @click="askDelete" />307+ </div>
225- <UButton label="Save" icon="i-lucide-save" :loading="saving" :disabled="!dirty || !!problems.length" @click="save" />308+
226- </template>309+ <template v-else>
227- </UDashboardNavbar>310+ <!-- Header: title, actions, fields or meta -->
228- </template>311+ <div class="flex flex-col gap-3 border-b border-sk-line2 px-[22px] py-5">
312+ <div class="flex items-start justify-between gap-3">
313+ <div class="flex min-w-0 flex-col gap-1">
314+ <div class="flex items-baseline gap-2.5">
315+ <h2 class="m-0 truncate text-lg font-semibold tracking-[-0.01em]" :class="!form.name && 'text-sk-faint'">{{ title }}</h2>
316+ <SkTypeTag :type="type" />
317+ <span v-if="editing && dirty" class="font-mono text-[11px] text-sk-accent-text">unsaved</span>
318+ </div>
319+ <p v-if="!editing && detail?.item.description" class="m-0 text-pretty text-sk-muted">{{ detail.item.description }}</p>
320+ </div>
321+ <div class="flex shrink-0 gap-1.5">
322+ <template v-if="!editing">
323+ <SkButton size="sm" label="Edit" @click="editing = true" />
324+ </template>
325+ <template v-else>
326+ <SkButton v-if="!isNew" size="sm" variant="danger" label="Delete" @click="askDelete" />
327+ <SkButton size="sm" label="Cancel" @click="cancel" />
328+ <SkButton size="sm" variant="primary" label="Save" :loading="saving" :disabled="!canSave" @click="save" />
329+ </template>
330+ </div>
331+ </div>
229 332
230- <template #body>333+ <!-- Edit: fields -->
231- <div v-if="loading" class="space-y-3">334+ <template v-if="editing">
232- <USkeleton class="h-10 w-1/2" />335+ <SkNote v-if="saveError" tone="accent">
233- <USkeleton class="h-72 w-full" />336+ <p class="m-0 font-medium">Save failed</p>
234- </div>337+ <p class="mt-1 mb-0 text-xs whitespace-pre-wrap">{{ saveError }}</p>
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 }]" />338+ </SkNote>
236-339+
237- <div v-else class="grid items-start gap-6 xl:grid-cols-[minmax(0,1fr)_20rem]">340+ <div class="grid gap-x-4 gap-y-3.5" :class="isMd ? 'md:grid-cols-2' : ''">
238- <!-- Main column -->341+ <CatalogField :label="renaming ? 'Name (renaming)' : 'Name'" required :error="nameError">
239- <div class="min-w-0 space-y-4">342+ <UInput
240- <UAlert v-if="saveError" color="error" variant="subtle" icon="i-lucide-circle-alert" title="Save failed" :description="saveError" class="whitespace-pre-wrap" />343+ :model-value="form.name"
241-344+ class="w-full"
242- <div class="grid gap-4 sm:grid-cols-2">345+ placeholder="kebab-case-name"
243- <UFormField :label="renaming ? 'Name (renaming)' : 'Name'" required>346+ :color="nameError ? 'error' : undefined"
244- <UInput :model-value="form.name" class="w-full" placeholder="kebab-case-name" :ui="{ base: 'font-mono' }" @update:model-value="onName" />347+ :highlight="!!nameError"
245- </UFormField>348+ :ui="{ base: 'font-mono text-xs' }"
246- <UFormField v-if="isMd" label="Description" required :description="type === 'skill' ? 'Tells the agent when to use the skill.' : undefined">349+ @update:model-value="onName"
247- <UInput v-model="description" class="w-full" />350+ />
248- </UFormField>351+ </CatalogField>
352+ <CatalogField v-if="isMd" label="Description" required :hint="type === 'skill' ? 'Tells the agent when to use the skill.' : undefined">
353+ <UInput v-model="description" class="w-full" :placeholder="type === 'rule' ? 'What the rule enforces' : 'When to use this'" />
354+ </CatalogField>
355+ <CatalogField v-if="type === 'rule'" label="Applies to" hint="Globs, one per line. Empty: always loaded." class="md:col-span-2">
356+ <UTextarea
357+ :model-value="pathsText"
358+ class="w-full"
359+ :rows="3"
360+ placeholder="src/**/*.ts&#10;app/**/*.vue"
361+ :ui="{ base: 'font-mono text-xs' }"
362+ aria-label="paths globs"
363+ @update:model-value="onPaths"
364+ />
365+ </CatalogField>
366+ <CatalogField label="Tags" hint="Press Enter to add" class="md:col-span-2">
367+ <UInputTags v-model="form.tags" add-on-blur placeholder="Add a tag" class="w-full" />
368+ </CatalogField>
249 </div>369 </div>
250 370
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." />371+ <SkNote v-if="renaming">
372+ Renaming <span class="font-mono text-xs">{{ name }}</span> to <span class="font-mono text-xs">{{ form.name }}</span>.
373+ Scopes that reference the old name report it as missing until they are updated.
374+ </SkNote>
375+ <SkNote v-if="problems.length && (dirty || !isNew)" tone="accent">
376+ <p class="m-0 font-medium">Fix before saving</p>
377+ <ul class="mt-1 mb-0 list-disc pl-4 text-xs">
378+ <li v-for="p in problems" :key="p">{{ p }}</li>
379+ </ul>
380+ </SkNote>
381+ </template>
252 382
253- <UAlert v-if="problems.length && (dirty || !isNew)" color="warning" variant="subtle" icon="i-lucide-triangle-alert" title="Fix before saving">383+ <!-- View: meta grid -->
254- <template #description>384+ <SkMetaGrid v-else label-width="96px" class="text-xs">
255- <ul class="list-disc pl-4">385+ <SkMeta label="Tags">
256- <li v-for="p in problems" :key="p">{{ p }}</li>386+ <span v-if="detail?.item.tags.length" class="flex flex-wrap gap-1"><SkTag v-for="t in detail.item.tags" :key="t">{{ t }}</SkTag></span>
257- </ul>387+ <span v-else class="text-sk-faint">No tags</span>
258- </template>388+ </SkMeta>
259- </UAlert>389+ <SkMeta v-if="type === 'rule'" label="Applies to" :mono="!!rulePaths.length">
260- <UAlert390+ <template v-if="rulePaths.length"><div v-for="p in rulePaths" :key="p">{{ p }}</div></template>
261- v-else-if="nameMismatch"391+ <span v-else class="font-sans text-sk-muted">Every file (always loaded)</span>
262- color="warning"392+ </SkMeta>
263- variant="subtle"393+ <SkMeta v-if="type === 'mcp' && tpl" label="Transport"><span class="text-sk-muted">{{ (tpl as McpTemplate).transport }}</span></SkMeta>
264- icon="i-lucide-triangle-alert"394+ <SkMeta v-if="type === 'hook' && tpl" label="Event"><span class="font-mono text-sk-muted">{{ (tpl as HookTemplate).event }}<template v-if="(tpl as HookTemplate).matcher"> · {{ (tpl as HookTemplate).matcher }}</template></span></SkMeta>
265- :title="mismatchTitle"395+ <SkMeta label="Renders for"><span class="text-sk-muted">{{ type === 'hook' ? hookAgents : 'Claude Code, Codex' }}</span></SkMeta>
266- description="It will be set to the item name when you save."396+ <SkMeta label="Source">
267- />397+ <span class="text-sk-muted">{{ originLabel }}</span>
398+ <span v-if="detail?.item.origin?.source" class="ml-1.5 font-mono text-[11px] text-sk-faint">{{ detail.item.origin.source }}</span>
399+ </SkMeta>
400+ <SkMeta v-if="envNames.length" label="Env vars" mono><span class="text-sk-muted">{{ envNames.join(', ') }}</span></SkMeta>
401+ <SkMeta label="Used in"><CatalogUsedBy :scopes="usedBy" /></SkMeta>
402+ <SkMeta v-if="detail" label="Updated"><span class="text-sk-muted">{{ formatDate(detail.item.updatedAt) }}</span></SkMeta>
403+ <SkMeta v-if="detail" label="Path" mono><span class="break-all text-sk-faint" :title="detail.item.path">{{ detail.item.path }}</span></SkMeta>
404+ </SkMetaGrid>
405+ </div>
268 406
269- <!-- Markdown editor -->407+ <!-- View: body -->
408+ <template v-if="!editing">
409+ <div class="flex flex-col gap-3 px-[22px] py-5">
270 <template v-if="isMd">410 <template v-if="isMd">
271- <div class="flex items-center justify-between">411+ <MarkdownBody v-if="parsed.body.trim()" :html="bodyHtml" />
272- <h2 class="text-sm font-semibold">{{ type === 'skill' ? 'SKILL.md' : `${name ?? form.name}.md` }}</h2>412+ <p v-else class="m-0 text-sk-faint">This item has no content yet.</p>
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>413 </template>
293-
294- <!-- MCP / hook form -->
295 <template v-else>414 <template v-else>
296- <div class="grid items-start gap-6 lg:grid-cols-2">415+ <SkSectionLabel>{{ yamlLabel }}</SkSectionLabel>
297- <McpForm v-if="type === 'mcp'" v-model="mcp" :key="detail?.item.hash ?? 'new'" />416+ <SkCode :text="yaml" />
298- <HookForm v-else v-model="hook" />417+ <p v-if="envNames.length" class="m-0 text-pretty text-sk-muted">
299- <div class="space-y-2">418+ The value of {{ envNames.join(', ') }} is read from your environment. It is never stored in the catalog.
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>419+ </p>
301- <pre class="overflow-auto rounded-lg border border-default bg-elevated p-3 font-mono text-xs">{{ yaml }}</pre>420+ <p v-if="type === 'hook'" class="m-0 text-xs text-sk-faint">skilly never runs the command: the agent does.</p>
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>421 </template>
306- </div>
307 422
308- <!-- Side column -->423+ <div v-if="type === 'skill' && existingFiles.length" class="flex flex-col gap-2 pt-1">
309- <aside class="space-y-5">424+ <SkSectionLabel :count="existingFiles.length">Extra files</SkSectionLabel>
310- <section class="space-y-1.5">425+ <ul class="m-0 flex list-none flex-col p-0">
311- <h3 class="text-sm font-semibold">Tags</h3>426+ <li v-for="f in existingFiles" :key="f.path" class="flex items-center gap-3 border-t border-sk-line2 py-1.5 first:border-t-0">
312- <UInputTags v-model="form.tags" add-on-blur placeholder="Add a tag, press Enter" class="w-full" />427+ <button type="button" class="min-w-0 flex-1 cursor-pointer truncate border-0 bg-transparent p-0 text-left font-mono text-xs text-sk-text hover:text-sk-accent-text" @click="viewFile = f.path">{{ f.path }}</button>
313- </section>428+ <span class="font-mono text-[11px] text-sk-faint">{{ formatBytes(f.sizeBytes) }}</span>
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>429 </li>
324 </ul>430 </ul>
325- <p v-else class="text-xs text-muted">Not used by any scope.</p>431+ </div>
326- </section>432+ </div>
327-433+ </template>
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 434
355- <div class="space-y-1.5 rounded-lg border border-dashed border-default p-2">435+ <!-- Edit: Markdown split editor -->
356- <UInput v-model="newFile.path" size="sm" placeholder="references/notes.md" class="w-full" :ui="{ base: 'font-mono' }" />436+ <template v-else-if="isMd">
357- <p v-if="newFileError" class="text-xs text-error">{{ newFileError }}</p>437+ <div class="flex items-center justify-between gap-3 px-[22px] py-2.5">
358- <textarea v-model="newFile.content" :class="TA" class="h-24" placeholder="Text content" spellcheck="false" aria-label="New file content" />438+ <span class="truncate font-mono text-[11px] text-sk-faint">{{ fileLabel || 'content' }}</span>
359- <UButton label="Add file" icon="i-lucide-plus" size="xs" color="neutral" variant="outline" :disabled="!newFile.path.trim() || !!newFileError" @click="addFile" />439+ <SkSeg v-model="view" :options="EDIT_VIEWS" size="xs" aria-label="Editor view" />
360- </div>440+ </div>
361- </section>441+ <div class="grid min-h-[420px] border-t border-sk-line2" :class="view === 'split' ? 'md:grid-cols-2' : ''">
362- </aside>442+ <textarea
363- </div>443+ v-if="view !== 'preview'"
444+ v-model="form.content"
445+ :class="[TA, view === 'split' && 'md:border-r md:border-sk-line2']"
446+ class="h-[520px] rounded-bl-[20px]"
447+ spellcheck="false"
448+ aria-label="Markdown content"
449+ />
450+ <div v-if="view !== 'edit'" class="flex h-[520px] flex-col gap-2.5 overflow-auto px-5 py-[18px]">
451+ <SkSectionLabel>Preview</SkSectionLabel>
452+ <MarkdownBody :html="bodyHtml" />
453+ </div>
454+ </div>
364 455
365- <!-- Skill file viewer -->456+ <!-- Skill files -->
366- <USlideover :open="!!viewFile" :title="viewFile ?? ''" :ui="{ content: 'sm:max-w-2xl' }" @update:open="(v: boolean) => { if (!v) viewFile = null }">457+ <div v-if="type === 'skill'" class="flex flex-col gap-3 border-t border-sk-line2 px-[22px] py-5">
367- <template #body>458+ <SkSectionLabel :count="existingFiles.length + Object.keys(extraFiles).length">Extra files</SkSectionLabel>
368- <MarkdownView v-if="viewFile && detail" :path="joinPath(detail.item.path, viewFile)" />459+ <ul v-if="existingFiles.length || Object.keys(extraFiles).length" class="m-0 flex list-none flex-col p-0">
369- </template>460+ <li v-for="f in existingFiles" :key="f.path" class="flex items-center gap-3 border-t border-sk-line2 py-1.5 first:border-t-0" :class="deleteFiles.includes(f.path) && 'opacity-55'">
370- </USlideover>461+ <button type="button" class="min-w-0 flex-1 cursor-pointer truncate border-0 bg-transparent p-0 text-left font-mono text-xs text-sk-text hover:text-sk-accent-text" :class="deleteFiles.includes(f.path) && 'line-through'" @click="viewFile = f.path">{{ f.path }}</button>
371-462+ <span class="font-mono text-[11px] text-sk-faint">{{ formatBytes(f.sizeBytes) }}</span>
372- <!-- Delete -->463+ <SkButton size="xs" variant="ghost" :label="deleteFiles.includes(f.path) ? 'Keep' : 'Delete'" @click="toggleDelete(f.path)" />
373- <UModal v-model:open="delOpen" :title="`Delete ${name}`" description="This removes the item from the catalog.">464+ </li>
374- <template #body>465+ <li v-for="(_c, p) in extraFiles" :key="p" class="flex items-center gap-3 border-t border-sk-line2 py-1.5 first:border-t-0">
375- <div class="space-y-3 text-sm">466+ <span class="min-w-0 flex-1 truncate font-mono text-xs">{{ p }}</span>
376- <p>467+ <SkTag mono>new</SkTag>
377- <strong>{{ name }}</strong> will be deleted from the catalog folder. Scopes where it was already written keep their files,468+ <SkButton size="xs" variant="ghost" label="Discard" @click="dropNew(p as string)" />
378- but the item will be reported as missing in the catalog.469+ </li>
379- </p>470+ </ul>
380- <UAlert471+ <p v-else class="m-0 text-sk-faint">SKILL.md only.</p>
381- v-if="delConflict || usedBy.length"472+
382- color="warning"473+ <div class="flex flex-col gap-2 rounded-[10px] border border-dashed border-sk-line p-3">
383- variant="subtle"474+ <UInput v-model="newFile.path" placeholder="references/notes.md" class="w-full" :ui="{ base: 'font-mono text-xs' }" aria-label="New file path" />
384- icon="i-lucide-triangle-alert"475+ <span v-if="newFileError" class="text-xs text-sk-accent-text">{{ newFileError }}</span>
385- :title="delConflict ? 'This item is used' : 'Used by'"476+ <UTextarea v-model="newFile.content" :rows="4" placeholder="Text content" class="w-full" :ui="{ base: 'font-mono text-xs' }" aria-label="New file content" />
386- >477+ <div><SkButton size="sm" icon="i-lucide-plus" label="Add file" :disabled="!newFile.path.trim() || !!newFileError" @click="addFile" /></div>
387- <template #description>
388- <p v-if="delConflict" class="mb-1">Still referenced by the configuration of these scopes. If you delete it anyway, they will report it as missing from the catalog.</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>478 </div>
395- </template>479+ </div>
396- <template #footer>480+ </template>
397- <div class="flex w-full justify-end gap-2">481+
398- <UButton label="Cancel" color="neutral" variant="outline" @click="delOpen = false" />482+ <!-- Edit: MCP / hook form + YAML -->
399- <UButton :label="delConflict ? 'Delete anyway' : 'Delete'" color="error" :loading="deleting" @click="doDelete(!!delConflict)" />483+ <template v-else>
484+ <div class="flex items-center justify-between gap-3 px-[22px] py-2.5">
485+ <span class="truncate font-mono text-[11px] text-sk-faint">{{ yamlLabel }}</span>
486+ <SkSeg v-model="formView" :options="FORM_VIEWS" size="xs" aria-label="Editor view" />
487+ </div>
488+ <div class="border-t border-sk-line2 px-[22px] py-5">
489+ <template v-if="formView === 'form'">
490+ <McpForm v-if="type === 'mcp'" v-model="mcp" :key="detail?.item.hash ?? 'new'" />
491+ <HookForm v-else v-model="hook" />
492+ </template>
493+ <div v-else class="flex flex-col gap-2">
494+ <SkCode :text="yaml" max-height="520px" />
495+ <p class="m-0 text-xs text-sk-faint">Read-only: generated from the form.</p>
400 </div>496 </div>
401- </template>497+ </div>
402- </UModal>498+ </template>
403 </template>499 </template>
404- </UDashboardPanel>500+
501+ <SkDrawer :open="!!viewFile" :title="viewFile ?? ''" width="lg" @update:open="(v: boolean) => { if (!v) viewFile = null }">
502+ <MarkdownView v-if="viewFile && detail" :path="joinPath(detail.item.path, viewFile)" />
503+ </SkDrawer>
504+
505+ <CatalogDeleteModal
506+ v-model:open="delOpen"
507+ kind="item"
508+ :name="name ?? ''"
509+ :used-by="usedBy"
510+ :conflict="delConflict"
511+ :deleting="deleting"
512+ @confirm="doDelete"
513+ />
514+ </SkPanel>
405 </template>515 </template>
modified packages/ui/app/components/CatalogSetupCard.vue +14 -12
@@ -1,4 +1,5 @@
11 <script setup lang="ts">
2+/** Setup state of the Catalog pages: the catalog folder does not exist yet. */
23 const { request } = useApi()
34 const { init, refresh } = useCatalog()
45 const toast = useToast()
@@ -26,17 +27,18 @@ async function create() {
2627 </script>
2728
2829 <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" />
30+ <SkPanel class="mx-auto w-full max-w-xl" padded>
31+ <div class="flex flex-col items-center gap-3 py-4 text-center">
32+ <h2 class="m-0 text-base font-semibold">No catalog yet</h2>
33+ <p class="m-0 max-w-md text-pretty text-sk-muted">
34+ The catalog is a git repository holding your skills, rules, MCP servers, hooks, subagents and presets.
35+ Create one at the location below, or point skilly to an existing repository in Settings.
36+ </p>
37+ <p class="m-0 max-w-full truncate rounded-[7px] border border-sk-line2 bg-sk-bg px-3 py-1.5 font-mono text-xs" :title="path">{{ path || '...' }}</p>
38+ <div class="flex gap-2">
39+ <SkButton variant="primary" label="Create catalog" :loading="creating" :disabled="!path" @click="create" />
40+ <SkButton label="Change location" to="/settings" />
41+ </div>
4042 </div>
41- </div>
43+ </SkPanel>
4244 </template>
@@ -1,4 +1,5 @@
1 <script setup lang="ts">1 <script setup lang="ts">
2+/** Setup state of the Catalog pages: the catalog folder does not exist yet. */
2 const { request } = useApi()3 const { request } = useApi()
3 const { init, refresh } = useCatalog()4 const { init, refresh } = useCatalog()
4 const toast = useToast()5 const toast = useToast()
@@ -26,17 +27,18 @@ async function create() {
26 </script>27 </script>
27 28
28 <template>29 <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+ <SkPanel class="mx-auto w-full max-w-xl" padded>
30- <UIcon name="i-lucide-library" class="size-10 text-dimmed" />31+ <div class="flex flex-col items-center gap-3 py-4 text-center">
31- <h2 class="font-semibold">No catalog yet</h2>32+ <h2 class="m-0 text-base font-semibold">No catalog yet</h2>
32- <p class="text-sm text-muted">33+ <p class="m-0 max-w-md text-pretty text-sk-muted">
33- The catalog is a git repository holding your skills, rules, MCP servers, hooks, agents and presets.34+ The catalog is a git repository holding your skills, rules, MCP servers, hooks, subagents and presets.
34- Create one at the location below, or point skilly to an existing repository in Settings.35+ Create one at the location below, or point skilly to an existing repository in Settings.
35- </p>36+ </p>
36- <p class="max-w-full truncate rounded bg-elevated px-2 py-1 font-mono text-xs" :title="path">{{ path || '...' }}</p>37+ <p class="m-0 max-w-full truncate rounded-[7px] border border-sk-line2 bg-sk-bg px-3 py-1.5 font-mono text-xs" :title="path">{{ path || '...' }}</p>
37- <div class="flex gap-2">38+ <div class="flex gap-2">
38- <UButton label="Create catalog" icon="i-lucide-folder-plus" :loading="creating" :disabled="!path" @click="create" />39+ <SkButton variant="primary" label="Create catalog" :loading="creating" :disabled="!path" @click="create" />
39- <UButton label="Change location" to="/settings" icon="i-lucide-settings" color="neutral" variant="outline" />40+ <SkButton label="Change location" to="/settings" />
41+ </div>
40 </div>42 </div>
41- </div>43+ </SkPanel>
42 </template>44 </template>
added packages/ui/app/components/CatalogUsedBy.vue +18 -0
new file mode 100644
@@ -0,0 +1,18 @@
1+<script setup lang="ts">
2+/** "Used in" links: scope ids (project ids or `home`) rendered as underlined links, or a faint fallback. */
3+defineProps<{ scopes: string[], empty?: string }>()
4+const names = useScopeNames()
5+</script>
6+
7+<template>
8+ <span class="flex flex-wrap gap-x-3 gap-y-1">
9+ <NuxtLink
10+ v-for="s in scopes"
11+ :key="s"
12+ :to="scopeLink(s)"
13+ class="text-sk-text underline decoration-sk-line underline-offset-[3px] hover:text-sk-accent-text"
14+ @click.stop
15+ >{{ scopeLabel(s, names) }}</NuxtLink>
16+ <span v-if="!scopes.length" class="text-sk-faint">{{ empty ?? 'No project yet' }}</span>
17+ </span>
18+</template>
new file mode 100644
@@ -0,0 +1,18 @@
1+<script setup lang="ts">
2+/** "Used in" links: scope ids (project ids or `home`) rendered as underlined links, or a faint fallback. */
3+defineProps<{ scopes: string[], empty?: string }>()
4+const names = useScopeNames()
5+</script>
6+
7+<template>
8+ <span class="flex flex-wrap gap-x-3 gap-y-1">
9+ <NuxtLink
10+ v-for="s in scopes"
11+ :key="s"
12+ :to="scopeLink(s)"
13+ class="text-sk-text underline decoration-sk-line underline-offset-[3px] hover:text-sk-accent-text"
14+ @click.stop
15+ >{{ scopeLabel(s, names) }}</NuxtLink>
16+ <span v-if="!scopes.length" class="text-sk-faint">{{ empty ?? 'No project yet' }}</span>
17+ </span>
18+</template>
deleted packages/ui/app/components/CountChips.vue +0 -15
deleted file mode 100644
@@ -1,15 +0,0 @@
1-<script setup lang="ts">
2-import type { ItemType } from '@skilly/core'
3-
4-defineProps<{ counts: Record<ItemType, number>, compact?: boolean }>()
5-</script>
6-
7-<template>
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" />
11- <span class="font-medium tabular-nums">{{ counts[t] ?? 0 }}</span>
12- <span class="text-muted">{{ TYPE_META[t].label }}</span>
13- </div>
14- </div>
15-</template>
deleted file mode 100644
@@ -1,15 +0,0 @@
1-<script setup lang="ts">
2-import type { ItemType } from '@skilly/core'
3-
4-defineProps<{ counts: Record<ItemType, number>, compact?: boolean }>()
5-</script>
6-
7-<template>
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" />
11- <span class="font-medium tabular-nums">{{ counts[t] ?? 0 }}</span>
12- <span class="text-muted">{{ TYPE_META[t].label }}</span>
13- </div>
14- </div>
15-</template>
modified packages/ui/app/components/EnvRows.vue +21 -18
@@ -32,35 +32,38 @@ function removeRow(i: number) {
3232 rows.value.splice(i, 1)
3333 push()
3434 }
35+function setMode(r: Row, mode: 'env' | 'literal') {
36+ r.mode = mode
37+ push()
38+}
3539
3640 const MODES = [
37- { label: 'From env var', value: 'env' },
38- { label: 'Literal value', value: 'literal' }
41+ { label: 'From env var', value: 'env' as const },
42+ { label: 'Literal', value: 'literal' as const }
3943 ]
4044 </script>
4145
4246 <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+ <div class="flex flex-col gap-2">
48+ <div v-for="(r, i) in rows" :key="i" class="flex flex-wrap items-center gap-2">
49+ <UInput v-model="r.key" :placeholder="keyPlaceholder" :aria-label="keyLabel" class="w-44" :ui="{ base: 'font-mono text-xs' }" @update:model-value="push" />
50+ <SkSeg :model-value="r.mode" :options="MODES" size="xs" aria-label="Value source" @update:model-value="(v) => v && setMode(r, v)" />
4751 <UInput
4852 v-model="r.val"
4953 :placeholder="r.mode === 'env' ? 'ENV_VAR_NAME' : 'value (stored in clear)'"
50- class="flex-1"
51- :ui="{ base: 'font-mono' }"
54+ :aria-label="r.mode === 'env' ? 'Environment variable name' : 'Literal value'"
55+ class="min-w-40 flex-1"
56+ :ui="{ base: 'font-mono text-xs' }"
5257 @update:model-value="push"
5358 />
54- <UButton icon="i-lucide-x" color="neutral" variant="ghost" size="sm" aria-label="Remove row" @click="removeRow(i)" />
59+ <SkButton variant="ghost" size="xs" icon="i-lucide-x" aria-label="Remove row" @click="removeRow(i)" />
60+ </div>
61+ <div>
62+ <SkButton size="xs" icon="i-lucide-plus" label="Add row" @click="add" />
5563 </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- />
64+ <SkNote v-if="hasLiteral" tone="accent">
65+ <strong class="font-semibold">Literal values are stored in clear in the catalog.</strong>
66+ The catalog is a git repository that may be shared. Use a literal only for non-secret settings; for tokens and keys, choose "From env var".
67+ </SkNote>
6568 </div>
6669 </template>
@@ -32,35 +32,38 @@ function removeRow(i: number) {
32 rows.value.splice(i, 1)32 rows.value.splice(i, 1)
33 push()33 push()
34 }34 }
35+function setMode(r: Row, mode: 'env' | 'literal') {
36+ r.mode = mode
37+ push()
38+}
35 39
36 const MODES = [40 const MODES = [
37- { label: 'From env var', value: 'env' },41+ { label: 'From env var', value: 'env' as const },
38- { label: 'Literal value', value: 'literal' }42+ { label: 'Literal', value: 'literal' as const }
39 ]43 ]
40 </script>44 </script>
41 45
42 <template>46 <template>
43- <div class="space-y-2">47+ <div class="flex flex-col gap-2">
44- <div v-for="(r, i) in rows" :key="i" class="flex items-center gap-2">48+ <div v-for="(r, i) in rows" :key="i" class="flex flex-wrap 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" />49+ <UInput v-model="r.key" :placeholder="keyPlaceholder" :aria-label="keyLabel" class="w-44" :ui="{ base: 'font-mono text-xs' }" @update:model-value="push" />
46- <USelect v-model="r.mode" :items="MODES" class="w-40" @update:model-value="push" />50+ <SkSeg :model-value="r.mode" :options="MODES" size="xs" aria-label="Value source" @update:model-value="(v) => v && setMode(r, v)" />
47 <UInput51 <UInput
48 v-model="r.val"52 v-model="r.val"
49 :placeholder="r.mode === 'env' ? 'ENV_VAR_NAME' : 'value (stored in clear)'"53 :placeholder="r.mode === 'env' ? 'ENV_VAR_NAME' : 'value (stored in clear)'"
50- class="flex-1"54+ :aria-label="r.mode === 'env' ? 'Environment variable name' : 'Literal value'"
51- :ui="{ base: 'font-mono' }"55+ class="min-w-40 flex-1"
56+ :ui="{ base: 'font-mono text-xs' }"
52 @update:model-value="push"57 @update:model-value="push"
53 />58 />
54- <UButton icon="i-lucide-x" color="neutral" variant="ghost" size="sm" aria-label="Remove row" @click="removeRow(i)" />59+ <SkButton variant="ghost" size="xs" icon="i-lucide-x" aria-label="Remove row" @click="removeRow(i)" />
60+ </div>
61+ <div>
62+ <SkButton size="xs" icon="i-lucide-plus" label="Add row" @click="add" />
55 </div>63 </div>
56- <UButton label="Add row" icon="i-lucide-plus" size="xs" color="neutral" variant="outline" @click="add" />64+ <SkNote v-if="hasLiteral" tone="accent">
57- <UAlert65+ <strong class="font-semibold">Literal values are stored in clear in the catalog.</strong>
58- v-if="hasLiteral"66+ The catalog is a git repository that may be shared. Use a literal only for non-secret settings; for tokens and keys, choose "From env var".
59- color="warning"67+ </SkNote>
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>68 </div>
66 </template>69 </template>
modified packages/ui/app/components/FolderBrowser.vue +29 -26
@@ -5,7 +5,6 @@ import type { FsListing } from '@skilly/core'
55 const props = defineProps<{ start?: string, height?: string }>()
66 const selected = defineModel<string | null>('selected', { default: null })
77
8-
98 const { request } = useApi()
109 const listing = ref<FsListing | null>(null)
1110 const loading = ref(false)
@@ -36,52 +35,56 @@ function highlight(path: string) {
3635 function reset() { browse(props.start || undefined) }
3736 defineExpose({ reset })
3837 onMounted(reset)
38+
39+const isDrive = (name: string) => /^[a-zA-Z]:[\\/]?$/.test(name)
3940 </script>
4041
4142 <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()" />
43+ <div class="flex flex-col gap-2.5">
44+ <nav class="flex flex-wrap items-center gap-0.5 font-mono text-xs" aria-label="Folder path">
45+ <button type="button" class="cursor-pointer rounded-md border-0 bg-transparent px-1.5 py-1 text-sk-muted hover:bg-sk-line2 hover:text-sk-text" @click="browse()">This PC</button>
4546 <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)" />
47+ <span class="text-sk-faint" aria-hidden="true">/</span>
48+ <button type="button" class="cursor-pointer rounded-md border-0 bg-transparent px-1.5 py-1 text-sk-muted hover:bg-sk-line2 hover:text-sk-text" @click="browse(c.path)">{{ c.name }}</button>
4849 </template>
49- </div>
50+ </nav>
5051
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">
52+ <div class="overflow-y-auto rounded-[10px] border border-sk-line" :class="height ?? 'h-72'">
53+ <div v-if="loading" class="flex flex-col gap-1 p-2">
5354 <USkeleton v-for="i in 6" :key="i" class="h-8 w-full" />
5455 </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>
56+ <SkNote v-else-if="listError" tone="accent" class="m-2">Cannot list this folder: {{ listError }}</SkNote>
57+ <ul v-else-if="listing" class="m-0 list-none p-0">
58+ <li v-if="listing.parent" class="border-b border-sk-line2">
59+ <button type="button" class="flex w-full cursor-pointer items-center gap-2 border-0 bg-transparent px-3.5 py-2 text-left text-sk-muted hover:bg-sk-hover" @click="browse(listing.parent!)">..</button>
6460 </li>
6561 <li
6662 v-for="e in listing.entries"
6763 :key="e.path"
68- class="flex items-center hover:bg-elevated/60"
69- :class="highlighted === e.path ? 'bg-primary/10' : ''"
64+ class="flex items-center border-t border-sk-line2 first:border-t-0 hover:bg-sk-hover"
65+ :class="highlighted === e.path && 'bg-sk-sel hover:bg-sk-sel'"
7066 >
7167 <button
7268 type="button"
73- class="flex min-w-0 flex-1 items-center gap-2 py-2 pl-3 text-left"
69+ class="flex min-w-0 flex-1 cursor-pointer items-center gap-2.5 border-0 bg-transparent py-2 pr-2 pl-3.5 text-left text-sk-text"
7470 :title="e.path"
7571 @click="highlight(e.path)"
7672 @dblclick="browse(e.path)"
7773 >
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" />
74+ <span class="truncate" :class="highlighted === e.path && 'font-medium'">{{ e.name }}</span>
75+ <span v-if="isDrive(e.name) && !listing.path" class="font-mono text-[11px] text-sk-faint">drive</span>
76+ <span v-if="e.hasAgentConfig" class="ml-auto shrink-0 font-mono text-[11px] text-sk-faint">agent config</span>
77+ </button>
78+ <button
79+ type="button"
80+ class="mr-2 flex size-6 shrink-0 cursor-pointer items-center justify-center rounded-md border-0 bg-transparent text-sk-faint hover:bg-sk-line2 hover:text-sk-text"
81+ :aria-label="`Open ${e.name}`"
82+ @click="browse(e.path)"
83+ >
84+ <UIcon name="i-lucide-chevron-right" class="size-3.5" />
8185 </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)" />
8386 </li>
84- <li v-if="!listing.entries.length" class="px-3 py-8 text-center text-muted">No subfolder here.</li>
87+ <li v-if="!listing.entries.length" class="px-3 py-8 text-center text-sk-faint">No subfolder here.</li>
8588 </ul>
8689 </div>
8790 </div>
@@ -5,7 +5,6 @@ import type { FsListing } from '@skilly/core'
5 const props = defineProps<{ start?: string, height?: string }>()5 const props = defineProps<{ start?: string, height?: string }>()
6 const selected = defineModel<string | null>('selected', { default: null })6 const selected = defineModel<string | null>('selected', { default: null })
7 7
8-
9 const { request } = useApi()8 const { request } = useApi()
10 const listing = ref<FsListing | null>(null)9 const listing = ref<FsListing | null>(null)
11 const loading = ref(false)10 const loading = ref(false)
@@ -36,52 +35,56 @@ function highlight(path: string) {
36 function reset() { browse(props.start || undefined) }35 function reset() { browse(props.start || undefined) }
37 defineExpose({ reset })36 defineExpose({ reset })
38 onMounted(reset)37 onMounted(reset)
38+
39+const isDrive = (name: string) => /^[a-zA-Z]:[\\/]?$/.test(name)
39 </script>40 </script>
40 41
41 <template>42 <template>
42- <div class="space-y-3">43+ <div class="flex flex-col gap-2.5">
43- <div class="flex flex-wrap items-center gap-1 text-sm">44+ <nav class="flex flex-wrap items-center gap-0.5 font-mono text-xs" aria-label="Folder path">
44- <UButton label="This PC" icon="i-lucide-hard-drive" size="xs" color="neutral" variant="ghost" @click="browse()" />45+ <button type="button" class="cursor-pointer rounded-md border-0 bg-transparent px-1.5 py-1 text-sk-muted hover:bg-sk-line2 hover:text-sk-text" @click="browse()">This PC</button>
45 <template v-for="c in crumbs" :key="c.path">46 <template v-for="c in crumbs" :key="c.path">
46- <UIcon name="i-lucide-chevron-right" class="size-3.5 text-dimmed" />47+ <span class="text-sk-faint" aria-hidden="true">/</span>
47- <UButton :label="c.name" size="xs" color="neutral" variant="ghost" @click="browse(c.path)" />48+ <button type="button" class="cursor-pointer rounded-md border-0 bg-transparent px-1.5 py-1 text-sk-muted hover:bg-sk-line2 hover:text-sk-text" @click="browse(c.path)">{{ c.name }}</button>
48 </template>49 </template>
49- </div>50+ </nav>
50 51
51- <div class="overflow-y-auto rounded-lg border border-default" :class="height ?? 'h-72'">52+ <div class="overflow-y-auto rounded-[10px] border border-sk-line" :class="height ?? 'h-72'">
52- <div v-if="loading" class="space-y-1 p-2">53+ <div v-if="loading" class="flex flex-col gap-1 p-2">
53 <USkeleton v-for="i in 6" :key="i" class="h-8 w-full" />54 <USkeleton v-for="i in 6" :key="i" class="h-8 w-full" />
54 </div>55 </div>
55- <div v-else-if="listError" class="p-3">56+ <SkNote v-else-if="listError" tone="accent" class="m-2">Cannot list this folder: {{ listError }}</SkNote>
56- <UAlert color="error" variant="subtle" icon="i-lucide-circle-alert" title="Cannot list this folder" :description="listError" />57+ <ul v-else-if="listing" class="m-0 list-none p-0">
57- </div>58+ <li v-if="listing.parent" class="border-b border-sk-line2">
58- <ul v-else-if="listing" class="divide-y divide-default text-sm">59+ <button type="button" class="flex w-full cursor-pointer items-center gap-2 border-0 bg-transparent px-3.5 py-2 text-left text-sk-muted hover:bg-sk-hover" @click="browse(listing.parent!)">..</button>
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>60 </li>
65 <li61 <li
66 v-for="e in listing.entries"62 v-for="e in listing.entries"
67 :key="e.path"63 :key="e.path"
68- class="flex items-center hover:bg-elevated/60"64+ class="flex items-center border-t border-sk-line2 first:border-t-0 hover:bg-sk-hover"
69- :class="highlighted === e.path ? 'bg-primary/10' : ''"65+ :class="highlighted === e.path && 'bg-sk-sel hover:bg-sk-sel'"
70 >66 >
71 <button67 <button
72 type="button"68 type="button"
73- class="flex min-w-0 flex-1 items-center gap-2 py-2 pl-3 text-left"69+ class="flex min-w-0 flex-1 cursor-pointer items-center gap-2.5 border-0 bg-transparent py-2 pr-2 pl-3.5 text-left text-sk-text"
74 :title="e.path"70 :title="e.path"
75 @click="highlight(e.path)"71 @click="highlight(e.path)"
76 @dblclick="browse(e.path)"72 @dblclick="browse(e.path)"
77 >73 >
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" />74+ <span class="truncate" :class="highlighted === e.path && 'font-medium'">{{ e.name }}</span>
79- <span class="flex-1 truncate">{{ e.name }}</span>75+ <span v-if="isDrive(e.name) && !listing.path" class="font-mono text-[11px] text-sk-faint">drive</span>
80- <UBadge v-if="e.hasAgentConfig" label="agent config" color="primary" variant="subtle" size="sm" icon="i-lucide-sparkles" class="shrink-0" />76+ <span v-if="e.hasAgentConfig" class="ml-auto shrink-0 font-mono text-[11px] text-sk-faint">agent config</span>
77+ </button>
78+ <button
79+ type="button"
80+ class="mr-2 flex size-6 shrink-0 cursor-pointer items-center justify-center rounded-md border-0 bg-transparent text-sk-faint hover:bg-sk-line2 hover:text-sk-text"
81+ :aria-label="`Open ${e.name}`"
82+ @click="browse(e.path)"
83+ >
84+ <UIcon name="i-lucide-chevron-right" class="size-3.5" />
81 </button>85 </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>86 </li>
84- <li v-if="!listing.entries.length" class="px-3 py-8 text-center text-muted">No subfolder here.</li>87+ <li v-if="!listing.entries.length" class="px-3 py-8 text-center text-sk-faint">No subfolder here.</li>
85 </ul>88 </ul>
86 </div>89 </div>
87 </div>90 </div>
modified packages/ui/app/components/FolderPickerModal.vue +6 -12
@@ -12,18 +12,12 @@ function confirm() {
1212 </script>
1313
1414 <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>
15+ <SkModal v-model:open="open" :title="title ?? 'Choose a folder'" description="Browse to a folder, then select it." width="lg">
16+ <FolderBrowser v-if="open" v-model:selected="selected" :start="start" />
1917 <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>
18+ <span class="mr-auto min-w-0 truncate font-mono text-[11px] text-sk-faint" :title="selected ?? ''">{{ selected ?? 'Select a drive or folder' }}</span>
19+ <SkButton label="Cancel" @click="open = false" />
20+ <SkButton variant="primary" label="Select this folder" :disabled="!selected" @click="confirm" />
2721 </template>
28- </UModal>
22+ </SkModal>
2923 </template>
@@ -12,18 +12,12 @@ function confirm() {
12 </script>12 </script>
13 13
14 <template>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' }">15+ <SkModal v-model:open="open" :title="title ?? 'Choose a folder'" description="Browse to a folder, then select it." width="lg">
16- <template #body>16+ <FolderBrowser v-if="open" v-model:selected="selected" :start="start" />
17- <FolderBrowser v-if="open" v-model:selected="selected" :start="start" />
18- </template>
19 <template #footer>17 <template #footer>
20- <div class="flex w-full items-center justify-between gap-2">18+ <span class="mr-auto min-w-0 truncate font-mono text-[11px] text-sk-faint" :title="selected ?? ''">{{ selected ?? 'Select a drive or folder' }}</span>
21- <span class="truncate font-mono text-xs text-muted" :title="selected ?? ''">{{ selected ?? 'Select a drive or folder' }}</span>19+ <SkButton label="Cancel" @click="open = false" />
22- <div class="flex shrink-0 gap-2">20+ <SkButton variant="primary" label="Select this folder" :disabled="!selected" @click="confirm" />
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>21 </template>
28- </UModal>22+ </SkModal>
29 </template>23 </template>
modified packages/ui/app/components/HookForm.vue +18 -17
@@ -23,34 +23,35 @@ function toggleAgent(a: AgentId, on: boolean | 'indeterminate') {
2323 </script>
2424
2525 <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>
26+ <div class="flex flex-col gap-4">
27+ <CatalogField label="Description">
28+ <UInput :model-value="t.description ?? ''" class="w-full" placeholder="What this hook does" @update:model-value="(v: string) => patch({ description: v || undefined })" />
29+ </CatalogField>
3030
3131 <div class="grid gap-4 sm:grid-cols-2">
32- <UFormField label="Event" required>
32+ <CatalogField label="Event" required>
3333 <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>
34+ </CatalogField>
35+ <CatalogField label="Matcher" hint="Tool name or regex; empty matches everything.">
36+ <UInput :model-value="t.matcher ?? ''" class="w-full" placeholder="Edit|Write" :ui="{ base: 'font-mono text-xs' }" @update:model-value="(v: string) => patch({ matcher: v || undefined })" />
37+ </CatalogField>
3838 </div>
3939
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>
40+ <CatalogField label="Command" required hint="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 text-xs' }" @update:model-value="(v: string) => patchHandler({ command: v })" />
42+ </CatalogField>
4343
4444 <div class="grid gap-4 sm:grid-cols-2">
45- <UFormField label="Timeout (seconds)">
45+ <CatalogField label="Timeout" hint="Seconds.">
4646 <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">
47+ </CatalogField>
48+ <div class="flex flex-col gap-1.5">
49+ <span class="flex flex-wrap items-baseline gap-x-2"><span class="font-medium">Agents</span><span class="text-xs text-sk-faint">None selected means all target agents.</span></span>
50+ <div class="flex gap-4 pt-1.5">
5051 <UCheckbox label="Claude Code" :model-value="agents.includes('claude')" @update:model-value="(v) => toggleAgent('claude', v)" />
5152 <UCheckbox label="Codex" :model-value="agents.includes('codex')" @update:model-value="(v) => toggleAgent('codex', v)" />
5253 </div>
53- </UFormField>
54+ </div>
5455 </div>
5556 </div>
5657 </template>
@@ -23,34 +23,35 @@ function toggleAgent(a: AgentId, on: boolean | 'indeterminate') {
23 </script>23 </script>
24 24
25 <template>25 <template>
26- <div class="space-y-4">26+ <div class="flex flex-col gap-4">
27- <UFormField label="Description">27+ <CatalogField label="Description">
28- <UInput :model-value="t.description ?? ''" class="w-full" placeholder="What this hook does" @update:model-value="(v: string) => patch({ description: v })" />28+ <UInput :model-value="t.description ?? ''" class="w-full" placeholder="What this hook does" @update:model-value="(v: string) => patch({ description: v || undefined })" />
29- </UFormField>29+ </CatalogField>
30 30
31 <div class="grid gap-4 sm:grid-cols-2">31 <div class="grid gap-4 sm:grid-cols-2">
32- <UFormField label="Event" required>32+ <CatalogField 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 })" />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>34+ </CatalogField>
35- <UFormField label="Matcher" description="Tool name or regex; empty matches everything.">35+ <CatalogField label="Matcher" hint="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 })" />36+ <UInput :model-value="t.matcher ?? ''" class="w-full" placeholder="Edit|Write" :ui="{ base: 'font-mono text-xs' }" @update:model-value="(v: string) => patch({ matcher: v || undefined })" />
37- </UFormField>37+ </CatalogField>
38 </div>38 </div>
39 39
40- <UFormField label="Command" required description="Shell command run by the agent. skilly never executes it.">40+ <CatalogField label="Command" required hint="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 })" />41+ <UInput :model-value="t.handler.command" class="w-full" placeholder="prettier --write $FILE" :ui="{ base: 'font-mono text-xs' }" @update:model-value="(v: string) => patchHandler({ command: v })" />
42- </UFormField>42+ </CatalogField>
43 43
44 <div class="grid gap-4 sm:grid-cols-2">44 <div class="grid gap-4 sm:grid-cols-2">
45- <UFormField label="Timeout (seconds)">45+ <CatalogField label="Timeout" hint="Seconds.">
46 <UInputNumber :model-value="t.handler.timeout" :min="1" class="w-40" @update:model-value="(v: number | null | undefined) => patchHandler({ timeout: v || undefined })" />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>47+ </CatalogField>
48- <UFormField label="Agents" description="None selected means all target agents.">48+ <div class="flex flex-col gap-1.5">
49- <div class="flex gap-4 pt-1">49+ <span class="flex flex-wrap items-baseline gap-x-2"><span class="font-medium">Agents</span><span class="text-xs text-sk-faint">None selected means all target agents.</span></span>
50+ <div class="flex gap-4 pt-1.5">
50 <UCheckbox label="Claude Code" :model-value="agents.includes('claude')" @update:model-value="(v) => toggleAgent('claude', v)" />51 <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 <UCheckbox label="Codex" :model-value="agents.includes('codex')" @update:model-value="(v) => toggleAgent('codex', v)" />
52 </div>53 </div>
53- </UFormField>54+ </div>
54 </div>55 </div>
55 </div>56 </div>
56 </template>57 </template>
deleted packages/ui/app/components/ItemDetail.vue +0 -127
deleted file mode 100644
@@ -1,127 +0,0 @@
1-<script setup lang="ts">
2-import type { InventoryItem, SkillDetails } from '@skilly/core'
3-
4-const props = defineProps<{ item: InventoryItem | null }>()
5-const open = defineModel<boolean>('open', { default: false })
6-
7-const filePath = computed<string | null>(() => {
8- const it = props.item
9- if (!it) return null
10- if (it.type === 'skill') return joinPath(it.source.path, 'SKILL.md')
11- if (['rule', 'agent', 'instructions'].includes(it.type) && it.source.kind === 'file') return it.source.path
12- return null
13-})
14-
15-/** Detail rows whose value is a path, command or identifier (shown in monospace). */
16-const MONO_ROWS = new Set(['Source', 'Config key', 'Symlink target', 'Hash', 'Command', 'URL', 'Env vars', 'Literal env keys', 'Matcher', 'Tools', 'Imports', 'Applies to'])
17-
18-/** Skill file currently displayed (defaults to SKILL.md). */
19-const currentFile = ref<string | null>(null)
20-watch(() => props.item?.id, () => { currentFile.value = null })
21-const shownPath = computed(() => currentFile.value ?? filePath.value)
22-
23-// SKILL.md first: it is the file shown by default.
24-const skillFiles = computed(() => (props.item?.type === 'skill'
25- ? [...(props.item.details as SkillDetails).files].sort((a, b) => Number(b === 'SKILL.md') - Number(a === 'SKILL.md'))
26- : []))
27-
28-const frontmatter = computed(() => {
29- const d = props.item?.details as { frontmatter?: Record<string, unknown> } | undefined
30- return currentFile.value ? undefined : d?.frontmatter
31-})
32-
33-const rows = computed(() => {
34- const it = props.item
35- if (!it) return []
36- const r: [string, string][] = [
37- ['Type', it.type],
38- ['Agent', AGENT_LABELS[it.agent]],
39- ['Source', it.source.path]
40- ]
41- if (it.source.configKey) r.push(['Config key', it.source.configKey])
42- if (it.source.symlinkTarget) r.push(['Symlink target', it.source.symlinkTarget])
43- if (it.source.plugin) r.push(['Plugin', it.source.plugin])
44- if (it.hash) r.push(['Hash', it.hash.slice(0, 12)])
45- const d = it.details
46- if (d.type === 'skill') {
47- r.push(['Visibility', d.visibility])
48- if (d.visibilitySource) r.push(['Visibility set by', d.visibilitySource])
49- } else if (d.type === 'rule') {
50- r.push(['Applies to', d.paths.length ? d.paths.join(', ') : 'always'])
51- } else if (d.type === 'mcp') {
52- r.push(['Transport', d.transport])
53- if (d.command) r.push(['Command', [d.command, ...(d.args ?? [])].join(' ')])
54- if (d.url) r.push(['URL', d.url])
55- if (d.agentScope) r.push(['Scope', d.agentScope])
56- r.push(['Enabled', d.enabled ? 'yes' : 'no'])
57- if (d.envVarNames.length) r.push(['Env vars', d.envVarNames.join(', ')])
58- if (d.literalEnvKeys.length) r.push(['Literal env keys', d.literalEnvKeys.join(', ')])
59- } else if (d.type === 'hook') {
60- r.push(['Event', d.event])
61- if (d.matcher) r.push(['Matcher', d.matcher])
62- r.push(['Handler', d.handlerType])
63- if (d.command) r.push(['Command', d.command])
64- } else if (d.type === 'agent') {
65- if (d.model) r.push(['Model', d.model])
66- if (d.tools?.length) r.push(['Tools', d.tools.join(', ')])
67- } else if (d.type === 'instructions') {
68- r.push(['Size', formatBytes(d.sizeBytes)])
69- r.push(['Loaded', d.loaded ? 'yes' : `no${d.notLoadedReason ? ` - ${d.notLoadedReason}` : ''}`])
70- if (d.imports.length) r.push(['Imports', d.imports.join(', ')])
71- }
72- return r
73-})
74-</script>
75-
76-<template>
77- <USlideover v-model:open="open" :title="item?.name" :description="item?.description" :ui="{ content: 'sm:max-w-2xl', description: 'line-clamp-3' }">
78- <template #body>
79- <div v-if="item" class="space-y-5">
80- <UAlert
81- v-if="item.warnings.length"
82- color="warning"
83- variant="subtle"
84- icon="i-lucide-triangle-alert"
85- :title="item.warnings.length > 1 ? 'Warnings' : 'Warning'"
86- >
87- <template #description>
88- <ul class="list-disc space-y-0.5 pl-4">
89- <li v-for="w in item.warnings" :key="w">{{ w }}</li>
90- </ul>
91- </template>
92- </UAlert>
93-
94- <div class="overflow-hidden rounded-lg border border-default">
95- <table class="w-full text-sm">
96- <tbody class="divide-y divide-default">
97- <tr v-for="[k, v] in rows" :key="k">
98- <th class="w-36 bg-elevated/40 px-3 py-2 text-left align-top text-xs font-medium text-muted">{{ k }}</th>
99- <td class="px-3 py-2 text-xs" :class="MONO_ROWS.has(k) ? 'font-mono break-all' : 'break-words'">{{ v }}</td>
100- </tr>
101- </tbody>
102- </table>
103- </div>
104-
105- <div v-if="skillFiles.length" class="space-y-2">
106- <h3 class="text-sm font-semibold">Files ({{ skillFiles.length }})</h3>
107- <ul class="max-h-48 overflow-auto rounded-lg border border-default text-xs">
108- <li v-for="f in skillFiles" :key="f">
109- <button
110- type="button"
111- class="flex w-full items-center gap-2 px-3 py-1.5 text-left font-mono hover:bg-elevated/60"
112- :class="shownPath === joinPath(item.source.path, f) ? 'bg-elevated text-highlighted' : ''"
113- @click="currentFile = f === 'SKILL.md' ? null : joinPath(item.source.path, f)"
114- >
115- <UIcon name="i-lucide-file" class="size-3.5 shrink-0 text-muted" />
116- <span class="truncate">{{ f }}</span>
117- </button>
118- </li>
119- </ul>
120- </div>
121-
122- <MarkdownView v-if="shownPath" :path="shownPath" :frontmatter="frontmatter" />
123- <p v-else class="text-sm text-muted">This item is an entry of a configuration file: there is no separate file to display.</p>
124- </div>
125- </template>
126- </USlideover>
127-</template>
deleted file mode 100644
@@ -1,127 +0,0 @@
1-<script setup lang="ts">
2-import type { InventoryItem, SkillDetails } from '@skilly/core'
3-
4-const props = defineProps<{ item: InventoryItem | null }>()
5-const open = defineModel<boolean>('open', { default: false })
6-
7-const filePath = computed<string | null>(() => {
8- const it = props.item
9- if (!it) return null
10- if (it.type === 'skill') return joinPath(it.source.path, 'SKILL.md')
11- if (['rule', 'agent', 'instructions'].includes(it.type) && it.source.kind === 'file') return it.source.path
12- return null
13-})
14-
15-/** Detail rows whose value is a path, command or identifier (shown in monospace). */
16-const MONO_ROWS = new Set(['Source', 'Config key', 'Symlink target', 'Hash', 'Command', 'URL', 'Env vars', 'Literal env keys', 'Matcher', 'Tools', 'Imports', 'Applies to'])
17-
18-/** Skill file currently displayed (defaults to SKILL.md). */
19-const currentFile = ref<string | null>(null)
20-watch(() => props.item?.id, () => { currentFile.value = null })
21-const shownPath = computed(() => currentFile.value ?? filePath.value)
22-
23-// SKILL.md first: it is the file shown by default.
24-const skillFiles = computed(() => (props.item?.type === 'skill'
25- ? [...(props.item.details as SkillDetails).files].sort((a, b) => Number(b === 'SKILL.md') - Number(a === 'SKILL.md'))
26- : []))
27-
28-const frontmatter = computed(() => {
29- const d = props.item?.details as { frontmatter?: Record<string, unknown> } | undefined
30- return currentFile.value ? undefined : d?.frontmatter
31-})
32-
33-const rows = computed(() => {
34- const it = props.item
35- if (!it) return []
36- const r: [string, string][] = [
37- ['Type', it.type],
38- ['Agent', AGENT_LABELS[it.agent]],
39- ['Source', it.source.path]
40- ]
41- if (it.source.configKey) r.push(['Config key', it.source.configKey])
42- if (it.source.symlinkTarget) r.push(['Symlink target', it.source.symlinkTarget])
43- if (it.source.plugin) r.push(['Plugin', it.source.plugin])
44- if (it.hash) r.push(['Hash', it.hash.slice(0, 12)])
45- const d = it.details
46- if (d.type === 'skill') {
47- r.push(['Visibility', d.visibility])
48- if (d.visibilitySource) r.push(['Visibility set by', d.visibilitySource])
49- } else if (d.type === 'rule') {
50- r.push(['Applies to', d.paths.length ? d.paths.join(', ') : 'always'])
51- } else if (d.type === 'mcp') {
52- r.push(['Transport', d.transport])
53- if (d.command) r.push(['Command', [d.command, ...(d.args ?? [])].join(' ')])
54- if (d.url) r.push(['URL', d.url])
55- if (d.agentScope) r.push(['Scope', d.agentScope])
56- r.push(['Enabled', d.enabled ? 'yes' : 'no'])
57- if (d.envVarNames.length) r.push(['Env vars', d.envVarNames.join(', ')])
58- if (d.literalEnvKeys.length) r.push(['Literal env keys', d.literalEnvKeys.join(', ')])
59- } else if (d.type === 'hook') {
60- r.push(['Event', d.event])
61- if (d.matcher) r.push(['Matcher', d.matcher])
62- r.push(['Handler', d.handlerType])
63- if (d.command) r.push(['Command', d.command])
64- } else if (d.type === 'agent') {
65- if (d.model) r.push(['Model', d.model])
66- if (d.tools?.length) r.push(['Tools', d.tools.join(', ')])
67- } else if (d.type === 'instructions') {
68- r.push(['Size', formatBytes(d.sizeBytes)])
69- r.push(['Loaded', d.loaded ? 'yes' : `no${d.notLoadedReason ? ` - ${d.notLoadedReason}` : ''}`])
70- if (d.imports.length) r.push(['Imports', d.imports.join(', ')])
71- }
72- return r
73-})
74-</script>
75-
76-<template>
77- <USlideover v-model:open="open" :title="item?.name" :description="item?.description" :ui="{ content: 'sm:max-w-2xl', description: 'line-clamp-3' }">
78- <template #body>
79- <div v-if="item" class="space-y-5">
80- <UAlert
81- v-if="item.warnings.length"
82- color="warning"
83- variant="subtle"
84- icon="i-lucide-triangle-alert"
85- :title="item.warnings.length > 1 ? 'Warnings' : 'Warning'"
86- >
87- <template #description>
88- <ul class="list-disc space-y-0.5 pl-4">
89- <li v-for="w in item.warnings" :key="w">{{ w }}</li>
90- </ul>
91- </template>
92- </UAlert>
93-
94- <div class="overflow-hidden rounded-lg border border-default">
95- <table class="w-full text-sm">
96- <tbody class="divide-y divide-default">
97- <tr v-for="[k, v] in rows" :key="k">
98- <th class="w-36 bg-elevated/40 px-3 py-2 text-left align-top text-xs font-medium text-muted">{{ k }}</th>
99- <td class="px-3 py-2 text-xs" :class="MONO_ROWS.has(k) ? 'font-mono break-all' : 'break-words'">{{ v }}</td>
100- </tr>
101- </tbody>
102- </table>
103- </div>
104-
105- <div v-if="skillFiles.length" class="space-y-2">
106- <h3 class="text-sm font-semibold">Files ({{ skillFiles.length }})</h3>
107- <ul class="max-h-48 overflow-auto rounded-lg border border-default text-xs">
108- <li v-for="f in skillFiles" :key="f">
109- <button
110- type="button"
111- class="flex w-full items-center gap-2 px-3 py-1.5 text-left font-mono hover:bg-elevated/60"
112- :class="shownPath === joinPath(item.source.path, f) ? 'bg-elevated text-highlighted' : ''"
113- @click="currentFile = f === 'SKILL.md' ? null : joinPath(item.source.path, f)"
114- >
115- <UIcon name="i-lucide-file" class="size-3.5 shrink-0 text-muted" />
116- <span class="truncate">{{ f }}</span>
117- </button>
118- </li>
119- </ul>
120- </div>
121-
122- <MarkdownView v-if="shownPath" :path="shownPath" :frontmatter="frontmatter" />
123- <p v-else class="text-sm text-muted">This item is an entry of a configuration file: there is no separate file to display.</p>
124- </div>
125- </template>
126- </USlideover>
127-</template>
modified packages/ui/app/components/ItemPicker.vue +76 -50
@@ -2,78 +2,104 @@
22 import type { CatalogItem, CatalogItemType } from '@skilly/core'
33
44 /**
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).
5+ * Checkbox picker over the catalog, grouped by type, with a filter.
6+ * `locked` maps `type/name` to a label ("via Base"): those rows are shown checked at reduced opacity and cannot be toggled
7+ * (already provided elsewhere).
8+ * `height` is a Tailwind height class for the scrollable list (default `h-72`).
9+ * `title`: when set, the picker renders as a full panel (title + filter in the header, rows edge to edge, no inner scroll),
10+ * as in the preset editor. Without it the picker is a bordered, scrollable list (modals, drawers).
711 */
812 const props = withDefaults(defineProps<{
913 items: CatalogItem[]
1014 types?: CatalogItemType[]
1115 locked?: Record<string, string>
1216 height?: string
13-}>(), { types: () => CAT_TYPES, locked: () => ({}), height: 'h-72' })
17+ title?: string
18+}>(), { types: () => CAT_ORDER, locked: () => ({}), height: 'h-72', title: undefined })
1419
1520 const selected = defineModel<Partial<Record<CatalogItemType, string[]>>>({ required: true })
1621
17-const tab = ref<CatalogItemType>(props.types[0] ?? 'skill')
1822 const search = ref('')
1923 const LIMIT = 200
20-const showAll = ref(false)
24+const expanded = ref<Partial<Record<CatalogItemType, boolean>>>({})
25+watch(search, () => { expanded.value = {} })
2126
22-watch([tab, search], () => { showAll.value = false })
27+const matches = (i: CatalogItem, q: string) => !q || `${i.name} ${i.description ?? ''} ${i.tags.join(' ')}`.toLowerCase().includes(q)
2328
24-const available = computed(() => props.items.filter(i => i.type === tab.value).sort((a, b) => a.name.localeCompare(b.name)))
25-const filtered = computed(() => {
29+const groups = computed(() => {
2630 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
31+ return props.types.map((t) => {
32+ const all = props.items.filter(i => i.type === t && matches(i, q)).sort((a, b) => a.name.localeCompare(b.name))
33+ return { type: t, total: all.length, rows: expanded.value[t] ? all : all.slice(0, LIMIT) }
34+ })
2835 })
29-const shown = computed(() => (showAll.value ? filtered.value : filtered.value.slice(0, LIMIT)))
36+const empty = computed(() => groups.value.every(g => !g.total))
3037
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-})))
38+const lockedLabel = (i: CatalogItem) => props.locked[itemKey(i.type, i.name)]
39+const isOn = (i: CatalogItem) => !!lockedLabel(i) || !!selected.value[i.type]?.includes(i.name)
40+const count = (t: CatalogItemType) => selected.value[t]?.length ?? 0
3741
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] }
42+function toggle(i: CatalogItem, on?: boolean | 'indeterminate') {
43+ if (lockedLabel(i)) return
44+ const cur = new Set(selected.value[i.type] ?? [])
45+ const next = on === undefined ? !cur.has(i.name) : on === true
46+ if (next) cur.add(i.name)
47+ else cur.delete(i.name)
48+ selected.value = { ...selected.value, [i.type]: [...cur] }
4549 }
4650 </script>
4751
4852 <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>
53+ <div class="flex min-w-0 flex-col" :class="title && 'overflow-hidden rounded-[20px] bg-sk-panel shadow-sk-ring'">
54+ <div class="flex items-center justify-between gap-3" :class="title ? 'px-5 py-3.5' : 'pb-2.5'">
55+ <span v-if="title" class="font-semibold">{{ title }}</span>
56+ <UInput
57+ v-model="search"
58+ :placeholder="title ? 'Filter items' : 'Search items'"
59+ aria-label="Filter items"
60+ :size="title ? 'sm' : 'md'"
61+ :class="title ? 'w-[220px] max-w-1/2' : 'w-full'"
62+ />
63+ </div>
64+
65+ <div
66+ :class="title ? '' : ['overflow-y-auto rounded-[11px] border border-sk-line2 bg-sk-panel', height]"
67+ >
68+ <template v-for="g in groups" :key="g.type">
69+ <template v-if="g.total">
70+ <SkSectionLabel
71+ class="border-t border-sk-line2 bg-sk-panel first:border-t-0"
72+ :class="title ? 'px-5 pt-2.5 pb-1.5' : 'px-3.5 pt-2.5 pb-1.5'"
73+ :count="count(g.type) ? `${count(g.type)}/${g.total}` : g.total"
74+ >{{ CAT_META[g.type].label }}</SkSectionLabel>
75+ <div
76+ v-for="i in g.rows"
77+ :key="i.name"
78+ class="flex items-center gap-3 border-t border-sk-line2 py-2 hover:bg-sk-hover"
79+ :class="[title ? 'px-5' : 'px-3.5', lockedLabel(i) ? 'cursor-not-allowed' : 'cursor-pointer']"
80+ @click="toggle(i)"
81+ >
82+ <span :class="lockedLabel(i) && 'opacity-45'" @click.stop>
83+ <UCheckbox
84+ :model-value="isOn(i)"
85+ :disabled="!!lockedLabel(i)"
86+ :aria-label="i.name"
87+ @update:model-value="(v) => toggle(i, v)"
88+ />
89+ </span>
90+ <span class="flex min-w-0 flex-1 flex-col">
91+ <span class="truncate font-medium">{{ i.name }}</span>
92+ <span v-if="i.description" class="truncate text-xs text-sk-muted" :title="i.description">{{ i.description }}</span>
93+ </span>
94+ <span v-if="lockedLabel(i)" class="text-xs whitespace-nowrap text-sk-faint">{{ lockedLabel(i) }}</span>
95+ </div>
96+ <div v-if="g.total > g.rows.length" class="border-t border-sk-line2 p-2 text-center">
97+ <SkButton size="xs" variant="ghost" :label="`Show all ${g.total}`" @click="expanded = { ...expanded, [g.type]: true }" />
6998 </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.` }}
99+ </template>
100+ </template>
101+ <p v-if="empty" class="m-0 px-6 py-8 text-center text-sk-faint">
102+ {{ search ? 'Nothing matches.' : 'The catalog has no items of these types yet.' }}
77103 </p>
78104 </div>
79105 </div>
@@ -2,78 +2,104 @@
2 import type { CatalogItem, CatalogItemType } from '@skilly/core'2 import type { CatalogItem, CatalogItemType } from '@skilly/core'
3 3
4 /**4 /**
5- * Checkbox picker over the catalog, per type with search.5+ * Checkbox picker over the catalog, grouped by type, with a filter.
6- * `locked` maps `type/name` to a label: those rows are shown checked and disabled (already provided elsewhere).6+ * `locked` maps `type/name` to a label ("via Base"): those rows are shown checked at reduced opacity and cannot be toggled
7+ * (already provided elsewhere).
8+ * `height` is a Tailwind height class for the scrollable list (default `h-72`).
9+ * `title`: when set, the picker renders as a full panel (title + filter in the header, rows edge to edge, no inner scroll),
10+ * as in the preset editor. Without it the picker is a bordered, scrollable list (modals, drawers).
7 */11 */
8 const props = withDefaults(defineProps<{12 const props = withDefaults(defineProps<{
9 items: CatalogItem[]13 items: CatalogItem[]
10 types?: CatalogItemType[]14 types?: CatalogItemType[]
11 locked?: Record<string, string>15 locked?: Record<string, string>
12 height?: string16 height?: string
13-}>(), { types: () => CAT_TYPES, locked: () => ({}), height: 'h-72' })17+ title?: string
18+}>(), { types: () => CAT_ORDER, locked: () => ({}), height: 'h-72', title: undefined })
14 19
15 const selected = defineModel<Partial<Record<CatalogItemType, string[]>>>({ required: true })20 const selected = defineModel<Partial<Record<CatalogItemType, string[]>>>({ required: true })
16 21
17-const tab = ref<CatalogItemType>(props.types[0] ?? 'skill')
18 const search = ref('')22 const search = ref('')
19 const LIMIT = 20023 const LIMIT = 200
20-const showAll = ref(false)24+const expanded = ref<Partial<Record<CatalogItemType, boolean>>>({})
25+watch(search, () => { expanded.value = {} })
21 26
22-watch([tab, search], () => { showAll.value = false })27+const matches = (i: CatalogItem, q: string) => !q || `${i.name} ${i.description ?? ''} ${i.tags.join(' ')}`.toLowerCase().includes(q)
23 28
24-const available = computed(() => props.items.filter(i => i.type === tab.value).sort((a, b) => a.name.localeCompare(b.name)))29+const groups = computed(() => {
25-const filtered = computed(() => {
26 const q = search.value.trim().toLowerCase()30 const q = search.value.trim().toLowerCase()
27- return q ? available.value.filter(i => `${i.name} ${i.description ?? ''} ${i.tags.join(' ')}`.toLowerCase().includes(q)) : available.value31+ return props.types.map((t) => {
32+ const all = props.items.filter(i => i.type === t && matches(i, q)).sort((a, b) => a.name.localeCompare(b.name))
33+ return { type: t, total: all.length, rows: expanded.value[t] ? all : all.slice(0, LIMIT) }
34+ })
28 })35 })
29-const shown = computed(() => (showAll.value ? filtered.value : filtered.value.slice(0, LIMIT)))36+const empty = computed(() => groups.value.every(g => !g.total))
30 37
31-const tabs = computed(() => props.types.map(t => ({38+const lockedLabel = (i: CatalogItem) => props.locked[itemKey(i.type, i.name)]
32- label: CAT_META[t].label,39+const isOn = (i: CatalogItem) => !!lockedLabel(i) || !!selected.value[i.type]?.includes(i.name)
33- icon: CAT_META[t].icon,40+const count = (t: CatalogItemType) => selected.value[t]?.length ?? 0
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 41
38-const isOn = (name: string) => !!selected.value[tab.value]?.includes(name)42+function toggle(i: CatalogItem, on?: boolean | 'indeterminate') {
39-43+ if (lockedLabel(i)) return
40-function toggle(name: string, on: boolean | 'indeterminate') {44+ const cur = new Set(selected.value[i.type] ?? [])
41- const cur = new Set(selected.value[tab.value] ?? [])45+ const next = on === undefined ? !cur.has(i.name) : on === true
42- if (on === true) cur.add(name)46+ if (next) cur.add(i.name)
43- else cur.delete(name)47+ else cur.delete(i.name)
44- selected.value = { ...selected.value, [tab.value]: [...cur] }48+ selected.value = { ...selected.value, [i.type]: [...cur] }
45 }49 }
46 </script>50 </script>
47 51
48 <template>52 <template>
49- <div class="space-y-3">53+ <div class="flex min-w-0 flex-col" :class="title && 'overflow-hidden rounded-[20px] bg-sk-panel shadow-sk-ring'">
50- <UTabs v-model="tab" :items="tabs" :content="false" variant="link" size="sm" class="w-full" />54+ <div class="flex items-center justify-between gap-3" :class="title ? 'px-5 py-3.5' : 'pb-2.5'">
51- <UInput v-model="search" icon="i-lucide-search" size="sm" placeholder="Search..." class="w-full" />55+ <span v-if="title" class="font-semibold">{{ title }}</span>
52- <div class="overflow-y-auto rounded-lg border border-default" :class="height">56+ <UInput
53- <ul v-if="shown.length" class="divide-y divide-default">57+ v-model="search"
54- <li v-for="i in shown" :key="i.name" class="flex items-start gap-2 px-3 py-2 hover:bg-elevated/50">58+ :placeholder="title ? 'Filter items' : 'Search items'"
55- <UCheckbox59+ aria-label="Filter items"
56- class="mt-0.5"60+ :size="title ? 'sm' : 'md'"
57- :model-value="!!locked[itemKey(i.type, i.name)] || isOn(i.name)"61+ :class="title ? 'w-[220px] max-w-1/2' : 'w-full'"
58- :disabled="!!locked[itemKey(i.type, i.name)]"62+ />
59- :aria-label="i.name"63+ </div>
60- @update:model-value="(v) => toggle(i.name, v)"64+
61- />65+ <div
62- <div class="min-w-0 flex-1">66+ :class="title ? '' : ['overflow-y-auto rounded-[11px] border border-sk-line2 bg-sk-panel', height]"
63- <div class="flex items-center gap-2">67+ >
64- <span class="truncate text-sm font-medium">{{ i.name }}</span>68+ <template v-for="g in groups" :key="g.type">
65- <UBadge v-if="locked[itemKey(i.type, i.name)]" :label="locked[itemKey(i.type, i.name)]" size="sm" color="neutral" variant="subtle" />69+ <template v-if="g.total">
66- <UBadge v-for="t in i.tags.slice(0, 3)" :key="t" :label="t" size="sm" color="neutral" variant="outline" />70+ <SkSectionLabel
67- </div>71+ class="border-t border-sk-line2 bg-sk-panel first:border-t-0"
68- <p v-if="i.description" class="line-clamp-1 text-xs text-muted" :title="i.description">{{ i.description }}</p>72+ :class="title ? 'px-5 pt-2.5 pb-1.5' : 'px-3.5 pt-2.5 pb-1.5'"
73+ :count="count(g.type) ? `${count(g.type)}/${g.total}` : g.total"
74+ >{{ CAT_META[g.type].label }}</SkSectionLabel>
75+ <div
76+ v-for="i in g.rows"
77+ :key="i.name"
78+ class="flex items-center gap-3 border-t border-sk-line2 py-2 hover:bg-sk-hover"
79+ :class="[title ? 'px-5' : 'px-3.5', lockedLabel(i) ? 'cursor-not-allowed' : 'cursor-pointer']"
80+ @click="toggle(i)"
81+ >
82+ <span :class="lockedLabel(i) && 'opacity-45'" @click.stop>
83+ <UCheckbox
84+ :model-value="isOn(i)"
85+ :disabled="!!lockedLabel(i)"
86+ :aria-label="i.name"
87+ @update:model-value="(v) => toggle(i, v)"
88+ />
89+ </span>
90+ <span class="flex min-w-0 flex-1 flex-col">
91+ <span class="truncate font-medium">{{ i.name }}</span>
92+ <span v-if="i.description" class="truncate text-xs text-sk-muted" :title="i.description">{{ i.description }}</span>
93+ </span>
94+ <span v-if="lockedLabel(i)" class="text-xs whitespace-nowrap text-sk-faint">{{ lockedLabel(i) }}</span>
95+ </div>
96+ <div v-if="g.total > g.rows.length" class="border-t border-sk-line2 p-2 text-center">
97+ <SkButton size="xs" variant="ghost" :label="`Show all ${g.total}`" @click="expanded = { ...expanded, [g.type]: true }" />
69 </div>98 </div>
70- </li>99+ </template>
71- <li v-if="filtered.length > shown.length" class="p-2 text-center">100+ </template>
72- <UButton :label="`Show all ${filtered.length}`" size="xs" color="neutral" variant="link" @click="showAll = true" />101+ <p v-if="empty" class="m-0 px-6 py-8 text-center text-sk-faint">
73- </li>102+ {{ search ? 'Nothing matches.' : 'The catalog has no items of these types yet.' }}
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>103 </p>
78 </div>104 </div>
79 </div>105 </div>
deleted packages/ui/app/components/ItemTable.vue +0 -239
deleted file mode 100644
@@ -1,239 +0,0 @@
1-<script setup lang="ts">
2-import type {
3- AgentDetails, CatalogRef, HookDetails, InstructionsDetails, InventoryItem, ItemStatus, ItemType, McpDetails, RuleDetails, SkillDetails
4-} from '@skilly/core'
5-
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-}>()
23-
24-const skill = (i: InventoryItem) => i.details as SkillDetails
25-const rule = (i: InventoryItem) => i.details as RuleDetails
26-const mcp = (i: InventoryItem) => i.details as McpDetails
27-const hook = (i: InventoryItem) => i.details as HookDetails
28-const agent = (i: InventoryItem) => i.details as AgentDetails
29-const instr = (i: InventoryItem) => i.details as InstructionsDetails
30-
31-const VISIBILITY = {
32- 'auto': { label: 'auto', color: 'success' },
33- 'on-demand': { label: 'on demand', color: 'info' },
34- 'name-only': { label: 'name only', color: 'warning' },
35- 'off': { label: 'off', color: 'neutral' }
36-} as const
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-
58-const TH = 'px-3 py-2 text-left text-xs font-medium uppercase tracking-wide text-muted'
59-const TD = 'px-3 py-2 align-middle'
60-const MAX_ENV = 4
61-</script>
62-
63-<template>
64- <div
65- v-if="!items.length"
66- class="flex flex-col items-center gap-2 rounded-xl border border-dashed border-default py-12 text-center"
67- >
68- <UIcon :name="filtered ? 'i-lucide-search-x' : TYPE_META[type].icon" class="size-8 text-dimmed" />
69- <p class="text-sm text-muted">
70- {{ filtered ? 'No result matches the current filters.' : `No ${TYPE_META[type].label.toLowerCase()} found in this scope.` }}
71- </p>
72- </div>
73-
74- <!-- table-fixed with the same widths for the leading columns keeps columns aligned from one tab to the next -->
75- <div v-else class="overflow-x-auto rounded-xl border border-default">
76- <table class="w-full min-w-[960px] table-fixed text-sm">
77- <thead class="border-b border-default bg-elevated/40">
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>
104- </tr>
105- </thead>
106-
107- <tbody class="divide-y divide-default">
108- <tr
109- v-for="item in items"
110- :key="item.id"
111- class="cursor-pointer transition-colors hover:bg-elevated/50"
112- tabindex="0"
113- @click="$emit('select', item)"
114- @keydown.enter="$emit('select', item)"
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-
120- <!-- Hooks lead with the event, others with the name -->
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>
128- </div>
129- <p v-if="item.description && type !== 'hook'" class="line-clamp-1 text-xs text-muted" :title="item.description">{{ item.description }}</p>
130- </div>
131- </td>
132- <td :class="TD"><AgentBadge :agent="item.agent" compact /></td>
133- <td :class="TD">
134- <!-- Project instructions are never shared, so a catalog status would be meaningless. -->
135- <span v-if="item.type === 'instructions'" class="text-xs text-dimmed">-</span>
136- <StatusBadge v-else :status="statuses?.[item.id]" />
137- </td>
138-
139- <!-- skills -->
140- <template v-if="type === 'skill'">
141- <td :class="TD">
142- <UTooltip :text="skill(item).visibilitySource ?? 'default'">
143- <UBadge :label="VISIBILITY[skill(item).visibility].label" :color="VISIBILITY[skill(item).visibility].color" variant="subtle" size="sm" />
144- </UTooltip>
145- </td>
146- <td :class="TD">
147- <div class="flex flex-wrap gap-1">
148- <UBadge v-if="item.source.plugin" :label="`plugin: ${item.source.plugin}`" icon="i-lucide-puzzle" color="primary" variant="subtle" size="sm" />
149- <UTooltip v-if="item.source.symlinkTarget" :text="item.source.symlinkTarget">
150- <UBadge label="symlink" icon="i-lucide-link" color="neutral" variant="subtle" size="sm" />
151- </UTooltip>
152- <!-- rtl keeps the end of the path (the meaningful part) visible when truncated -->
153- <span v-if="item.source.symlinkTarget" dir="rtl" class="max-w-56 truncate text-left font-mono text-xs text-muted" :title="item.source.symlinkTarget">
154- <bdi>{{ item.source.symlinkTarget }}</bdi>
155- </span>
156- <span v-if="!item.source.plugin && !item.source.symlinkTarget" class="text-xs text-dimmed">local</span>
157- </div>
158- </td>
159- </template>
160-
161- <!-- rules -->
162- <template v-else-if="type === 'rule'">
163- <td :class="TD">
164- <span v-if="!rule(item).paths.length" class="text-xs text-muted">always</span>
165- <div v-else class="flex flex-wrap gap-1">
166- <UBadge v-for="p in rule(item).paths" :key="p" :label="p" color="neutral" variant="outline" size="sm" class="font-mono" />
167- </div>
168- </td>
169- </template>
170-
171- <!-- mcp -->
172- <template v-else-if="type === 'mcp'">
173- <td :class="TD"><UBadge :label="mcp(item).transport" color="neutral" variant="subtle" size="sm" /></td>
174- <td :class="TD">
175- <span class="block truncate font-mono text-xs" :title="mcp(item).url ?? [mcp(item).command, ...(mcp(item).args ?? [])].join(' ')">
176- {{ mcp(item).url ?? [mcp(item).command, ...(mcp(item).args ?? [])].filter(Boolean).join(' ') }}
177- </span>
178- </td>
179- <td :class="TD">
180- <div class="flex flex-wrap gap-1">
181- <UBadge v-for="v in mcp(item).envVarNames.slice(0, MAX_ENV)" :key="v" :label="v" color="neutral" variant="outline" size="sm" class="font-mono" />
182- <UBadge v-if="mcp(item).envVarNames.length > MAX_ENV" :label="`+${mcp(item).envVarNames.length - MAX_ENV}`" color="neutral" variant="subtle" size="sm" />
183- </div>
184- </td>
185- <td :class="TD"><span class="text-xs text-muted">{{ mcp(item).agentScope ?? '-' }}</span></td>
186- <td :class="TD">
187- <UBadge :label="mcp(item).enabled ? 'enabled' : 'disabled'" :color="mcp(item).enabled ? 'success' : 'neutral'" variant="subtle" size="sm" />
188- </td>
189- </template>
190-
191- <!-- hooks -->
192- <template v-else-if="type === 'hook'">
193- <td :class="TD"><span class="block truncate font-mono text-xs">{{ hook(item).matcher ?? '*' }}</span></td>
194- <td :class="TD">
195- <span class="block truncate font-mono text-xs" :title="hook(item).command">{{ hook(item).command ?? '-' }}</span>
196- </td>
197- <td :class="TD"><UBadge :label="hook(item).handlerType" color="neutral" variant="subtle" size="sm" /></td>
198- </template>
199-
200- <!-- agents -->
201- <template v-else-if="type === 'agent'">
202- <td :class="TD"><span class="text-xs">{{ agent(item).model ?? 'inherit' }}</span></td>
203- </template>
204-
205- <!-- instructions -->
206- <template v-else>
207- <td :class="TD"><span class="text-xs tabular-nums text-muted">{{ formatBytes(instr(item).sizeBytes) }}</span></td>
208- <td :class="TD">
209- <UTooltip v-if="!instr(item).loaded && instr(item).notLoadedReason" :text="instr(item).notLoadedReason!">
210- <UBadge label="not loaded" color="warning" variant="subtle" size="sm" icon="i-lucide-circle-help" />
211- </UTooltip>
212- <UBadge v-else :label="instr(item).loaded ? 'loaded' : 'not loaded'" :color="instr(item).loaded ? 'success' : 'warning'" variant="subtle" size="sm" />
213- </td>
214- <td :class="TD">
215- <div class="flex flex-wrap gap-1">
216- <UBadge v-for="im in instr(item).imports" :key="im" :label="im" color="neutral" variant="outline" size="sm" class="font-mono" />
217- <span v-if="!instr(item).imports.length" class="text-xs text-dimmed">-</span>
218- </div>
219- </td>
220- </template>
221-
222- <td v-if="actionable" :class="TD" @click.stop>
223- <div class="flex justify-end gap-0.5">
224- <UTooltip v-if="canImport(item)" text="Add to catalog">
225- <UButton icon="i-lucide-library" size="xs" color="neutral" variant="ghost" aria-label="Add to catalog" @click="emit('addToCatalog', item)" />
226- </UTooltip>
227- <UTooltip v-if="statuses?.[item.id]?.catalogRef" text="Open in catalog">
228- <UButton icon="i-lucide-external-link" size="xs" color="neutral" variant="ghost" aria-label="Open in catalog" @click="emit('openCatalog', statuses![item.id]!.catalogRef!)" />
229- </UTooltip>
230- <UTooltip v-if="canRemoveGlobal(item)" text="Remove from global">
231- <UButton icon="i-lucide-globe-lock" size="xs" color="neutral" variant="ghost" aria-label="Remove from global" @click="emit('removeGlobal', item)" />
232- </UTooltip>
233- </div>
234- </td>
235- </tr>
236- </tbody>
237- </table>
238- </div>
239-</template>
deleted file mode 100644
@@ -1,239 +0,0 @@
1-<script setup lang="ts">
2-import type {
3- AgentDetails, CatalogRef, HookDetails, InstructionsDetails, InventoryItem, ItemStatus, ItemType, McpDetails, RuleDetails, SkillDetails
4-} from '@skilly/core'
5-
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-}>()
23-
24-const skill = (i: InventoryItem) => i.details as SkillDetails
25-const rule = (i: InventoryItem) => i.details as RuleDetails
26-const mcp = (i: InventoryItem) => i.details as McpDetails
27-const hook = (i: InventoryItem) => i.details as HookDetails
28-const agent = (i: InventoryItem) => i.details as AgentDetails
29-const instr = (i: InventoryItem) => i.details as InstructionsDetails
30-
31-const VISIBILITY = {
32- 'auto': { label: 'auto', color: 'success' },
33- 'on-demand': { label: 'on demand', color: 'info' },
34- 'name-only': { label: 'name only', color: 'warning' },
35- 'off': { label: 'off', color: 'neutral' }
36-} as const
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-
58-const TH = 'px-3 py-2 text-left text-xs font-medium uppercase tracking-wide text-muted'
59-const TD = 'px-3 py-2 align-middle'
60-const MAX_ENV = 4
61-</script>
62-
63-<template>
64- <div
65- v-if="!items.length"
66- class="flex flex-col items-center gap-2 rounded-xl border border-dashed border-default py-12 text-center"
67- >
68- <UIcon :name="filtered ? 'i-lucide-search-x' : TYPE_META[type].icon" class="size-8 text-dimmed" />
69- <p class="text-sm text-muted">
70- {{ filtered ? 'No result matches the current filters.' : `No ${TYPE_META[type].label.toLowerCase()} found in this scope.` }}
71- </p>
72- </div>
73-
74- <!-- table-fixed with the same widths for the leading columns keeps columns aligned from one tab to the next -->
75- <div v-else class="overflow-x-auto rounded-xl border border-default">
76- <table class="w-full min-w-[960px] table-fixed text-sm">
77- <thead class="border-b border-default bg-elevated/40">
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>
104- </tr>
105- </thead>
106-
107- <tbody class="divide-y divide-default">
108- <tr
109- v-for="item in items"
110- :key="item.id"
111- class="cursor-pointer transition-colors hover:bg-elevated/50"
112- tabindex="0"
113- @click="$emit('select', item)"
114- @keydown.enter="$emit('select', item)"
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-
120- <!-- Hooks lead with the event, others with the name -->
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>
128- </div>
129- <p v-if="item.description && type !== 'hook'" class="line-clamp-1 text-xs text-muted" :title="item.description">{{ item.description }}</p>
130- </div>
131- </td>
132- <td :class="TD"><AgentBadge :agent="item.agent" compact /></td>
133- <td :class="TD">
134- <!-- Project instructions are never shared, so a catalog status would be meaningless. -->
135- <span v-if="item.type === 'instructions'" class="text-xs text-dimmed">-</span>
136- <StatusBadge v-else :status="statuses?.[item.id]" />
137- </td>
138-
139- <!-- skills -->
140- <template v-if="type === 'skill'">
141- <td :class="TD">
142- <UTooltip :text="skill(item).visibilitySource ?? 'default'">
143- <UBadge :label="VISIBILITY[skill(item).visibility].label" :color="VISIBILITY[skill(item).visibility].color" variant="subtle" size="sm" />
144- </UTooltip>
145- </td>
146- <td :class="TD">
147- <div class="flex flex-wrap gap-1">
148- <UBadge v-if="item.source.plugin" :label="`plugin: ${item.source.plugin}`" icon="i-lucide-puzzle" color="primary" variant="subtle" size="sm" />
149- <UTooltip v-if="item.source.symlinkTarget" :text="item.source.symlinkTarget">
150- <UBadge label="symlink" icon="i-lucide-link" color="neutral" variant="subtle" size="sm" />
151- </UTooltip>
152- <!-- rtl keeps the end of the path (the meaningful part) visible when truncated -->
153- <span v-if="item.source.symlinkTarget" dir="rtl" class="max-w-56 truncate text-left font-mono text-xs text-muted" :title="item.source.symlinkTarget">
154- <bdi>{{ item.source.symlinkTarget }}</bdi>
155- </span>
156- <span v-if="!item.source.plugin && !item.source.symlinkTarget" class="text-xs text-dimmed">local</span>
157- </div>
158- </td>
159- </template>
160-
161- <!-- rules -->
162- <template v-else-if="type === 'rule'">
163- <td :class="TD">
164- <span v-if="!rule(item).paths.length" class="text-xs text-muted">always</span>
165- <div v-else class="flex flex-wrap gap-1">
166- <UBadge v-for="p in rule(item).paths" :key="p" :label="p" color="neutral" variant="outline" size="sm" class="font-mono" />
167- </div>
168- </td>
169- </template>
170-
171- <!-- mcp -->
172- <template v-else-if="type === 'mcp'">
173- <td :class="TD"><UBadge :label="mcp(item).transport" color="neutral" variant="subtle" size="sm" /></td>
174- <td :class="TD">
175- <span class="block truncate font-mono text-xs" :title="mcp(item).url ?? [mcp(item).command, ...(mcp(item).args ?? [])].join(' ')">
176- {{ mcp(item).url ?? [mcp(item).command, ...(mcp(item).args ?? [])].filter(Boolean).join(' ') }}
177- </span>
178- </td>
179- <td :class="TD">
180- <div class="flex flex-wrap gap-1">
181- <UBadge v-for="v in mcp(item).envVarNames.slice(0, MAX_ENV)" :key="v" :label="v" color="neutral" variant="outline" size="sm" class="font-mono" />
182- <UBadge v-if="mcp(item).envVarNames.length > MAX_ENV" :label="`+${mcp(item).envVarNames.length - MAX_ENV}`" color="neutral" variant="subtle" size="sm" />
183- </div>
184- </td>
185- <td :class="TD"><span class="text-xs text-muted">{{ mcp(item).agentScope ?? '-' }}</span></td>
186- <td :class="TD">
187- <UBadge :label="mcp(item).enabled ? 'enabled' : 'disabled'" :color="mcp(item).enabled ? 'success' : 'neutral'" variant="subtle" size="sm" />
188- </td>
189- </template>
190-
191- <!-- hooks -->
192- <template v-else-if="type === 'hook'">
193- <td :class="TD"><span class="block truncate font-mono text-xs">{{ hook(item).matcher ?? '*' }}</span></td>
194- <td :class="TD">
195- <span class="block truncate font-mono text-xs" :title="hook(item).command">{{ hook(item).command ?? '-' }}</span>
196- </td>
197- <td :class="TD"><UBadge :label="hook(item).handlerType" color="neutral" variant="subtle" size="sm" /></td>
198- </template>
199-
200- <!-- agents -->
201- <template v-else-if="type === 'agent'">
202- <td :class="TD"><span class="text-xs">{{ agent(item).model ?? 'inherit' }}</span></td>
203- </template>
204-
205- <!-- instructions -->
206- <template v-else>
207- <td :class="TD"><span class="text-xs tabular-nums text-muted">{{ formatBytes(instr(item).sizeBytes) }}</span></td>
208- <td :class="TD">
209- <UTooltip v-if="!instr(item).loaded && instr(item).notLoadedReason" :text="instr(item).notLoadedReason!">
210- <UBadge label="not loaded" color="warning" variant="subtle" size="sm" icon="i-lucide-circle-help" />
211- </UTooltip>
212- <UBadge v-else :label="instr(item).loaded ? 'loaded' : 'not loaded'" :color="instr(item).loaded ? 'success' : 'warning'" variant="subtle" size="sm" />
213- </td>
214- <td :class="TD">
215- <div class="flex flex-wrap gap-1">
216- <UBadge v-for="im in instr(item).imports" :key="im" :label="im" color="neutral" variant="outline" size="sm" class="font-mono" />
217- <span v-if="!instr(item).imports.length" class="text-xs text-dimmed">-</span>
218- </div>
219- </td>
220- </template>
221-
222- <td v-if="actionable" :class="TD" @click.stop>
223- <div class="flex justify-end gap-0.5">
224- <UTooltip v-if="canImport(item)" text="Add to catalog">
225- <UButton icon="i-lucide-library" size="xs" color="neutral" variant="ghost" aria-label="Add to catalog" @click="emit('addToCatalog', item)" />
226- </UTooltip>
227- <UTooltip v-if="statuses?.[item.id]?.catalogRef" text="Open in catalog">
228- <UButton icon="i-lucide-external-link" size="xs" color="neutral" variant="ghost" aria-label="Open in catalog" @click="emit('openCatalog', statuses![item.id]!.catalogRef!)" />
229- </UTooltip>
230- <UTooltip v-if="canRemoveGlobal(item)" text="Remove from global">
231- <UButton icon="i-lucide-globe-lock" size="xs" color="neutral" variant="ghost" aria-label="Remove from global" @click="emit('removeGlobal', item)" />
232- </UTooltip>
233- </div>
234- </td>
235- </tr>
236- </tbody>
237- </table>
238- </div>
239-</template>
added packages/ui/app/components/MarkdownBody.vue +42 -0
new file mode 100644
@@ -0,0 +1,42 @@
1+<script setup lang="ts">
2+/**
3+ * Rendered Markdown (sanitised by utils/markdown.ts: raw HTML is escaped, only http(s)/mailto links).
4+ * Pass either the Markdown `source` (frontmatter is NOT stripped: split it first) or ready-made `html`.
5+ * The `.md-body` typography lives here (not in main.css): a raw `<div class="md-body">` elsewhere is unstyled.
6+ */
7+const props = defineProps<{ source?: string, html?: string }>()
8+const out = computed(() => props.html ?? renderMarkdown(props.source ?? ''))
9+</script>
10+
11+<template>
12+ <!-- eslint-disable-next-line vue/no-v-html -->
13+ <div class="md-body" v-html="out" />
14+</template>
15+
16+<style>
17+.md-body { font-size: 13px; line-height: 1.55; color: var(--sk-muted); overflow-wrap: anywhere; text-wrap: pretty; }
18+.md-body > :first-child { margin-top: 0; }
19+.md-body > :last-child { margin-bottom: 0; }
20+.md-body h1 { font-size: 17px; font-weight: 600; letter-spacing: -0.01em; color: var(--sk-text); margin: 14px 0 10px; line-height: 1.3; }
21+.md-body h2 { font-size: 14px; font-weight: 600; color: var(--sk-text); margin: 16px 0 8px; line-height: 1.3; }
22+.md-body h3, .md-body h4 { font-size: 13px; font-weight: 600; color: var(--sk-text); margin: 12px 0 6px; }
23+.md-body p { margin: 8px 0; }
24+.md-body strong { color: var(--sk-text); font-weight: 600; }
25+.md-body ul { list-style: none; padding: 0; margin: 8px 0; }
26+.md-body ul > li { position: relative; padding-left: 16px; margin: 3px 0; }
27+.md-body ul > li::before { content: '–'; position: absolute; left: 2px; color: var(--sk-faint); }
28+.md-body ol { list-style: decimal; padding-left: 20px; margin: 8px 0; }
29+.md-body ol > li { margin: 3px 0; padding-left: 2px; }
30+.md-body ol > li::marker { color: var(--sk-faint); font-family: var(--font-mono); font-size: 12px; }
31+.md-body li > ul, .md-body li > ol { margin: 2px 0; }
32+.md-body a { color: var(--sk-text); text-decoration: underline; text-decoration-color: var(--sk-line); text-underline-offset: 3px; }
33+.md-body a:hover { color: var(--sk-accent-text); }
34+.md-body code { font-family: var(--font-mono); background: var(--sk-line2); color: var(--sk-text); padding: 1px 5px; border-radius: 5px; font-size: 12px; }
35+.md-body pre { background: var(--sk-bg); border: 1px solid var(--sk-line2); padding: 12px 14px; border-radius: 8px; overflow-x: auto; margin: 10px 0; }
36+.md-body pre code { background: transparent; padding: 0; border-radius: 0; color: var(--sk-text); font-size: 12px; line-height: 1.6; }
37+.md-body blockquote { border-left: 2px solid var(--sk-line); padding-left: 12px; color: var(--sk-muted); margin: 8px 0; }
38+.md-body table { display: block; max-width: 100%; overflow-x: auto; border-collapse: collapse; margin: 10px 0; }
39+.md-body th, .md-body td { border: 1px solid var(--sk-line2); padding: 4px 10px; text-align: left; vertical-align: top; }
40+.md-body th { background: var(--sk-hover); color: var(--sk-text); font-weight: 600; }
41+.md-body hr { border: 0; border-top: 1px solid var(--sk-line2); margin: 14px 0; }
42+</style>
new file mode 100644
@@ -0,0 +1,42 @@
1+<script setup lang="ts">
2+/**
3+ * Rendered Markdown (sanitised by utils/markdown.ts: raw HTML is escaped, only http(s)/mailto links).
4+ * Pass either the Markdown `source` (frontmatter is NOT stripped: split it first) or ready-made `html`.
5+ * The `.md-body` typography lives here (not in main.css): a raw `<div class="md-body">` elsewhere is unstyled.
6+ */
7+const props = defineProps<{ source?: string, html?: string }>()
8+const out = computed(() => props.html ?? renderMarkdown(props.source ?? ''))
9+</script>
10+
11+<template>
12+ <!-- eslint-disable-next-line vue/no-v-html -->
13+ <div class="md-body" v-html="out" />
14+</template>
15+
16+<style>
17+.md-body { font-size: 13px; line-height: 1.55; color: var(--sk-muted); overflow-wrap: anywhere; text-wrap: pretty; }
18+.md-body > :first-child { margin-top: 0; }
19+.md-body > :last-child { margin-bottom: 0; }
20+.md-body h1 { font-size: 17px; font-weight: 600; letter-spacing: -0.01em; color: var(--sk-text); margin: 14px 0 10px; line-height: 1.3; }
21+.md-body h2 { font-size: 14px; font-weight: 600; color: var(--sk-text); margin: 16px 0 8px; line-height: 1.3; }
22+.md-body h3, .md-body h4 { font-size: 13px; font-weight: 600; color: var(--sk-text); margin: 12px 0 6px; }
23+.md-body p { margin: 8px 0; }
24+.md-body strong { color: var(--sk-text); font-weight: 600; }
25+.md-body ul { list-style: none; padding: 0; margin: 8px 0; }
26+.md-body ul > li { position: relative; padding-left: 16px; margin: 3px 0; }
27+.md-body ul > li::before { content: '–'; position: absolute; left: 2px; color: var(--sk-faint); }
28+.md-body ol { list-style: decimal; padding-left: 20px; margin: 8px 0; }
29+.md-body ol > li { margin: 3px 0; padding-left: 2px; }
30+.md-body ol > li::marker { color: var(--sk-faint); font-family: var(--font-mono); font-size: 12px; }
31+.md-body li > ul, .md-body li > ol { margin: 2px 0; }
32+.md-body a { color: var(--sk-text); text-decoration: underline; text-decoration-color: var(--sk-line); text-underline-offset: 3px; }
33+.md-body a:hover { color: var(--sk-accent-text); }
34+.md-body code { font-family: var(--font-mono); background: var(--sk-line2); color: var(--sk-text); padding: 1px 5px; border-radius: 5px; font-size: 12px; }
35+.md-body pre { background: var(--sk-bg); border: 1px solid var(--sk-line2); padding: 12px 14px; border-radius: 8px; overflow-x: auto; margin: 10px 0; }
36+.md-body pre code { background: transparent; padding: 0; border-radius: 0; color: var(--sk-text); font-size: 12px; line-height: 1.6; }
37+.md-body blockquote { border-left: 2px solid var(--sk-line); padding-left: 12px; color: var(--sk-muted); margin: 8px 0; }
38+.md-body table { display: block; max-width: 100%; overflow-x: auto; border-collapse: collapse; margin: 10px 0; }
39+.md-body th, .md-body td { border: 1px solid var(--sk-line2); padding: 4px 10px; text-align: left; vertical-align: top; }
40+.md-body th { background: var(--sk-hover); color: var(--sk-text); font-weight: 600; }
41+.md-body hr { border: 0; border-top: 1px solid var(--sk-line2); margin: 14px 0; }
42+</style>
modified packages/ui/app/components/MarkdownView.vue +10 -23
@@ -43,7 +43,7 @@ watch(() => props.path, load, { immediate: true })
4343 <template>
4444 <div class="space-y-3">
4545 <div class="flex items-center justify-between gap-2">
46- <span dir="rtl" class="truncate text-left font-mono text-xs text-muted" :title="path"><bdi>{{ path }}</bdi></span>
46+ <span dir="rtl" class="truncate text-left font-mono text-xs text-sk-faint" :title="path"><bdi>{{ path }}</bdi></span>
4747 <USwitch v-if="isMarkdown" v-model="raw" label="Raw" size="sm" class="shrink-0" />
4848 </div>
4949
@@ -52,31 +52,18 @@ watch(() => props.path, load, { immediate: true })
5252 <USkeleton class="h-4 w-full" />
5353 <USkeleton class="h-4 w-5/6" />
5454 </div>
55- <UAlert v-else-if="error" color="error" variant="subtle" icon="i-lucide-circle-alert" title="Could not read the file" :description="error" />
55+ <SkNote v-else-if="error" tone="accent">Could not read the file: {{ error }}</SkNote>
5656 <template v-else-if="file">
57- <UAlert
58- v-if="file.truncated"
59- color="warning"
60- variant="subtle"
61- icon="i-lucide-scissors"
62- title="Large file"
63- :description="`Only the first part of this file is shown (${formatBytes(file.sizeBytes)} in total).`"
64- />
57+ <SkNote v-if="file.truncated" tone="accent">
58+ Large file: only the first part is shown ({{ formatBytes(file.sizeBytes) }} in total).
59+ </SkNote>
6560
66- <pre v-if="raw || !isMarkdown" class="overflow-auto whitespace-pre-wrap rounded-lg bg-elevated p-3 font-mono text-xs">{{ file.content }}</pre>
61+ <SkCode v-if="raw || !isMarkdown" :text="file.content" />
6762 <template v-else>
68- <div v-if="fmRows.length" class="overflow-hidden rounded-lg border border-default">
69- <table class="w-full text-xs">
70- <tbody class="divide-y divide-default">
71- <tr v-for="[k, v] in fmRows" :key="k">
72- <th class="w-1/4 bg-elevated/40 px-2 py-1.5 text-left align-top font-medium text-muted">{{ k }}</th>
73- <td class="px-2 py-1.5 break-words">{{ v }}</td>
74- </tr>
75- </tbody>
76- </table>
77- </div>
78- <!-- eslint-disable-next-line vue/no-v-html -->
79- <div class="md-body" v-html="html" />
63+ <SkMetaGrid v-if="fmRows.length" label-width="90px" class="rounded-[10px] border border-sk-line2 bg-sk-bg px-3.5 py-3">
64+ <SkMeta v-for="[k, v] in fmRows" :key="k" :label="k">{{ v }}</SkMeta>
65+ </SkMetaGrid>
66+ <MarkdownBody :html="html" />
8067 </template>
8168 </template>
8269 </div>
@@ -43,7 +43,7 @@ watch(() => props.path, load, { immediate: true })
43 <template>43 <template>
44 <div class="space-y-3">44 <div class="space-y-3">
45 <div class="flex items-center justify-between gap-2">45 <div class="flex items-center justify-between gap-2">
46- <span dir="rtl" class="truncate text-left font-mono text-xs text-muted" :title="path"><bdi>{{ path }}</bdi></span>46+ <span dir="rtl" class="truncate text-left font-mono text-xs text-sk-faint" :title="path"><bdi>{{ path }}</bdi></span>
47 <USwitch v-if="isMarkdown" v-model="raw" label="Raw" size="sm" class="shrink-0" />47 <USwitch v-if="isMarkdown" v-model="raw" label="Raw" size="sm" class="shrink-0" />
48 </div>48 </div>
49 49
@@ -52,31 +52,18 @@ watch(() => props.path, load, { immediate: true })
52 <USkeleton class="h-4 w-full" />52 <USkeleton class="h-4 w-full" />
53 <USkeleton class="h-4 w-5/6" />53 <USkeleton class="h-4 w-5/6" />
54 </div>54 </div>
55- <UAlert v-else-if="error" color="error" variant="subtle" icon="i-lucide-circle-alert" title="Could not read the file" :description="error" />55+ <SkNote v-else-if="error" tone="accent">Could not read the file: {{ error }}</SkNote>
56 <template v-else-if="file">56 <template v-else-if="file">
57- <UAlert57+ <SkNote v-if="file.truncated" tone="accent">
58- v-if="file.truncated"58+ Large file: only the first part is shown ({{ formatBytes(file.sizeBytes) }} in total).
59- color="warning"59+ </SkNote>
60- variant="subtle"
61- icon="i-lucide-scissors"
62- title="Large file"
63- :description="`Only the first part of this file is shown (${formatBytes(file.sizeBytes)} in total).`"
64- />
65 60
66- <pre v-if="raw || !isMarkdown" class="overflow-auto whitespace-pre-wrap rounded-lg bg-elevated p-3 font-mono text-xs">{{ file.content }}</pre>61+ <SkCode v-if="raw || !isMarkdown" :text="file.content" />
67 <template v-else>62 <template v-else>
68- <div v-if="fmRows.length" class="overflow-hidden rounded-lg border border-default">63+ <SkMetaGrid v-if="fmRows.length" label-width="90px" class="rounded-[10px] border border-sk-line2 bg-sk-bg px-3.5 py-3">
69- <table class="w-full text-xs">64+ <SkMeta v-for="[k, v] in fmRows" :key="k" :label="k">{{ v }}</SkMeta>
70- <tbody class="divide-y divide-default">65+ </SkMetaGrid>
71- <tr v-for="[k, v] in fmRows" :key="k">66+ <MarkdownBody :html="html" />
72- <th class="w-1/4 bg-elevated/40 px-2 py-1.5 text-left align-top font-medium text-muted">{{ k }}</th>
73- <td class="px-2 py-1.5 break-words">{{ v }}</td>
74- </tr>
75- </tbody>
76- </table>
77- </div>
78- <!-- eslint-disable-next-line vue/no-v-html -->
79- <div class="md-body" v-html="html" />
80 </template>67 </template>
81 </template>68 </template>
82 </div>69 </div>
modified packages/ui/app/components/McpForm.vue +27 -24
@@ -5,8 +5,8 @@ import type { McpTemplate } from '@skilly/core'
55 const t = defineModel<McpTemplate>({ required: true })
66
77 const TRANSPORTS = [
8- { label: 'stdio (local command)', value: 'stdio' },
9- { label: 'http (remote URL)', value: 'http' }
8+ { label: 'stdio (local command)', value: 'stdio' as const },
9+ { label: 'http (remote URL)', value: 'http' as const }
1010 ]
1111
1212 const args = computed({
@@ -24,37 +24,40 @@ function patch(p: Partial<McpTemplate>) {
2424 </script>
2525
2626 <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>
27+ <div class="flex flex-col gap-4">
28+ <CatalogField label="Description">
29+ <UInput :model-value="t.description ?? ''" class="w-full" placeholder="What this server provides" @update:model-value="(v: string) => patch({ description: v || undefined })" />
30+ </CatalogField>
3131
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>
32+ <div class="flex flex-col gap-1.5">
33+ <span class="font-medium">Transport</span>
34+ <div><SkSeg :model-value="t.transport" :options="TRANSPORTS" aria-label="Transport" @update:model-value="(v) => v && patch({ transport: v })" /></div>
35+ </div>
3536
3637 <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.">
38+ <CatalogField label="Command" required>
39+ <UInput :model-value="t.command ?? ''" class="w-full" placeholder="npx" :ui="{ base: 'font-mono text-xs' }" @update:model-value="(v: string) => patch({ command: v })" />
40+ </CatalogField>
41+ <CatalogField label="Arguments" hint="Press Enter to add each argument.">
4142 <UInputTags v-model="args" add-on-blur add-on-paste class="w-full" placeholder="-y, @upstash/context7-mcp" />
42- </UFormField>
43- <UFormField label="Environment variables" description="Variables passed to the server process.">
43+ </CatalogField>
44+ <div class="flex flex-col gap-1.5">
45+ <span class="flex flex-wrap items-baseline gap-x-2"><span class="font-medium">Environment variables</span><span class="text-xs text-sk-faint">Passed to the server process.</span></span>
4446 <EnvRows :model-value="t.env" key-label="Variable name" key-placeholder="API_KEY" @update:model-value="(v) => patch({ env: v })" />
45- </UFormField>
47+ </div>
4648 </template>
4749
4850 <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">
51+ <CatalogField label="URL" required>
52+ <UInput :model-value="t.url ?? ''" class="w-full" placeholder="https://example.com/mcp" :ui="{ base: 'font-mono text-xs' }" @update:model-value="(v: string) => patch({ url: v })" />
53+ </CatalogField>
54+ <CatalogField label="Bearer token variable" hint="Name of the environment variable holding the token, never the token itself.">
55+ <UInput v-model="bearer" class="w-full max-w-sm" placeholder="GITHUB_TOKEN" :ui="{ base: 'font-mono text-xs' }" />
56+ </CatalogField>
57+ <div class="flex flex-col gap-1.5">
58+ <span class="font-medium">Headers</span>
5659 <EnvRows :model-value="t.headers" key-label="Header name" key-placeholder="X-Api-Key" @update:model-value="(v) => patch({ headers: v })" />
57- </UFormField>
60+ </div>
5861 </template>
5962 </div>
6063 </template>
@@ -5,8 +5,8 @@ import type { McpTemplate } from '@skilly/core'
5 const t = defineModel<McpTemplate>({ required: true })5 const t = defineModel<McpTemplate>({ required: true })
6 6
7 const TRANSPORTS = [7 const TRANSPORTS = [
8- { label: 'stdio (local command)', value: 'stdio' },8+ { label: 'stdio (local command)', value: 'stdio' as const },
9- { label: 'http (remote URL)', value: 'http' }9+ { label: 'http (remote URL)', value: 'http' as const }
10 ]10 ]
11 11
12 const args = computed({12 const args = computed({
@@ -24,37 +24,40 @@ function patch(p: Partial<McpTemplate>) {
24 </script>24 </script>
25 25
26 <template>26 <template>
27- <div class="space-y-4">27+ <div class="flex flex-col gap-4">
28- <UFormField label="Description">28+ <CatalogField label="Description">
29- <UInput :model-value="t.description ?? ''" class="w-full" placeholder="What this server provides" @update:model-value="(v: string) => patch({ description: v })" />29+ <UInput :model-value="t.description ?? ''" class="w-full" placeholder="What this server provides" @update:model-value="(v: string) => patch({ description: v || undefined })" />
30- </UFormField>30+ </CatalogField>
31 31
32- <UFormField label="Transport">32+ <div class="flex flex-col gap-1.5">
33- <USelect :model-value="t.transport" :items="TRANSPORTS" class="w-64" @update:model-value="(v: string) => patch({ transport: v as 'stdio' | 'http' })" />33+ <span class="font-medium">Transport</span>
34- </UFormField>34+ <div><SkSeg :model-value="t.transport" :options="TRANSPORTS" aria-label="Transport" @update:model-value="(v) => v && patch({ transport: v })" /></div>
35+ </div>
35 36
36 <template v-if="t.transport === 'stdio'">37 <template v-if="t.transport === 'stdio'">
37- <UFormField label="Command" required>38+ <CatalogField 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+ <UInput :model-value="t.command ?? ''" class="w-full" placeholder="npx" :ui="{ base: 'font-mono text-xs' }" @update:model-value="(v: string) => patch({ command: v })" />
39- </UFormField>40+ </CatalogField>
40- <UFormField label="Arguments" description="Press Enter to add each argument.">41+ <CatalogField label="Arguments" hint="Press Enter to add each argument.">
41 <UInputTags v-model="args" add-on-blur add-on-paste class="w-full" placeholder="-y, @upstash/context7-mcp" />42 <UInputTags v-model="args" add-on-blur add-on-paste class="w-full" placeholder="-y, @upstash/context7-mcp" />
42- </UFormField>43+ </CatalogField>
43- <UFormField label="Environment variables" description="Variables passed to the server process.">44+ <div class="flex flex-col gap-1.5">
45+ <span class="flex flex-wrap items-baseline gap-x-2"><span class="font-medium">Environment variables</span><span class="text-xs text-sk-faint">Passed to the server process.</span></span>
44 <EnvRows :model-value="t.env" key-label="Variable name" key-placeholder="API_KEY" @update:model-value="(v) => patch({ env: v })" />46 <EnvRows :model-value="t.env" key-label="Variable name" key-placeholder="API_KEY" @update:model-value="(v) => patch({ env: v })" />
45- </UFormField>47+ </div>
46 </template>48 </template>
47 49
48 <template v-else>50 <template v-else>
49- <UFormField label="URL" required>51+ <CatalogField 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 })" />52+ <UInput :model-value="t.url ?? ''" class="w-full" placeholder="https://example.com/mcp" :ui="{ base: 'font-mono text-xs' }" @update:model-value="(v: string) => patch({ url: v })" />
51- </UFormField>53+ </CatalogField>
52- <UFormField label="Bearer token env var" description="Name of the environment variable holding the token (never the token itself).">54+ <CatalogField label="Bearer token variable" hint="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' }" />55+ <UInput v-model="bearer" class="w-full max-w-sm" placeholder="GITHUB_TOKEN" :ui="{ base: 'font-mono text-xs' }" />
54- </UFormField>56+ </CatalogField>
55- <UFormField label="Headers">57+ <div class="flex flex-col gap-1.5">
58+ <span class="font-medium">Headers</span>
56 <EnvRows :model-value="t.headers" key-label="Header name" key-placeholder="X-Api-Key" @update:model-value="(v) => patch({ headers: v })" />59 <EnvRows :model-value="t.headers" key-label="Header name" key-placeholder="X-Api-Key" @update:model-value="(v) => patch({ headers: v })" />
57- </UFormField>60+ </div>
58 </template>61 </template>
59 </div>62 </div>
60 </template>63 </template>
modified packages/ui/app/components/PresetEditor.vue +176 -113
@@ -1,12 +1,11 @@
11 <script setup lang="ts">
22 import type { CatalogItemType, Preset } from '@skilly/core'
33
4-/** Preset editor. `name` is undefined for a new preset. */
4+/** Preset editor: name, description, included presets, items; resolved preview on the right. `name` is undefined for a new preset. */
55 const props = defineProps<{ name?: string }>()
66
77 const { request } = useApi()
88 const catalog = useCatalog()
9-const scopeNames = useScopeNames()
109 const toast = useToast()
1110
1211 const isNew = computed(() => !props.name)
@@ -39,33 +38,77 @@ async function init() {
3938 original.value = snapshot()
4039 ready.value = true
4140 }
42-onMounted(init)
41+onMounted(() => {
42+ init()
43+ catalog.refresh()
44+})
4345
44-const includeChoices = computed(() => catalog.presets.value.map(p => p.name).filter(n => n !== props.name && n !== form.name))
4546 const renaming = computed(() => !isNew.value && form.name !== props.name)
47+const others = computed(() => catalog.presets.value.filter(p => p.name !== props.name))
4648
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
49+/** Presets that (transitively) include `target`: including them here would create a cycle. */
50+function dependsOn(p: Preset, target: string, seen = new Set<string>()): boolean {
51+ if (seen.has(p.name)) return false
52+ seen.add(p.name)
53+ return p.includes.some(n => n === target || (others.value.find(x => x.name === n) ? dependsOn(others.value.find(x => x.name === n)!, target, seen) : false))
54+}
55+const includeChoices = computed(() => others.value
56+ .filter(p => p.name !== form.name)
57+ .map(p => ({
58+ name: p.name,
59+ count: resolveItems(catalog.presets.value, [p.name]).entries.length,
60+ blocked: !!props.name && dependsOn(p, props.name) && !form.includes.includes(p.name)
61+ })))
62+function toggleInclude(n: string) {
63+ form.includes = form.includes.includes(n) ? form.includes.filter(x => x !== n) : [...form.includes, n]
64+}
65+
66+const nameError = computed(() => {
67+ if (!form.name) return isNew.value && !dirty.value ? null : 'Give the preset a name.'
68+ if (!NAME_RE.test(form.name)) return 'Use kebab-case: lowercase letters, digits, "." "_" "-", starting with a letter or digit.'
69+ if ((isNew.value || renaming.value) && catalog.presets.value.some(x => x.name === form.name)) return 'A preset with this name already exists.'
70+ return null
5271 })
72+const canSave = computed(() => dirty.value && !!form.name && !nameError.value)
5373
74+const selfName = computed(() => form.name || props.name || '(this preset)')
5475 const resolved = computed(() => resolveItems(
55- catalog.presets.value.filter(p => p.name !== props.name),
76+ others.value,
5677 [],
5778 {},
58- { name: form.name || props.name || '(this preset)', includes: form.includes, items: form.items }
79+ { name: selfName.value, includes: form.includes, items: form.items }
5980 ))
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)
81+const inherited = computed(() => resolveItems(others.value, [], {}, { name: selfName.value, includes: form.includes, items: {} }))
82+/** Items provided by an included preset and not picked here: shown checked and locked in the picker. */
83+const locked = computed(() => {
84+ const out: Record<string, string> = {}
85+ for (const e of inherited.value.entries) {
86+ if (form.items[e.type]?.includes(e.name)) continue
87+ out[itemKey(e.type, e.name)] = `via ${e.via.filter(v => v !== selfName.value).join(', ')}`
88+ }
89+ return out
90+})
91+
92+const resolvedRows = computed(() => [...resolved.value.entries]
93+ .sort((a, b) => CAT_ORDER.indexOf(a.type) - CAT_ORDER.indexOf(b.type) || a.name.localeCompare(b.name))
94+ .map(e => ({ ...e, from: e.via.filter(v => v !== selfName.value), own: e.via.includes(selfName.value) })))
95+const PLURAL: Record<CatalogItemType, [string, string]> = {
96+ skill: ['skill', 'skills'], rule: ['rule', 'rules'], mcp: ['MCP server', 'MCP servers'], hook: ['hook', 'hooks'], agent: ['subagent', 'subagents']
97+}
98+const countText = computed(() => {
99+ const n = resolvedRows.value.length
100+ if (!n) return 'Nothing yet'
101+ const parts = CAT_ORDER.map((t) => {
102+ const c = resolvedRows.value.filter(r => r.type === t).length
103+ return c ? `${c} ${PLURAL[t][c === 1 ? 0 : 1]}` : ''
104+ }).filter(Boolean)
105+ return `${n} item${n > 1 ? 's' : ''} · ${parts.join(', ')}`
106+})
107+const isMissing = (t: CatalogItemType, n: string) => !catalog.findItem(t, n)
108+const usedBy = computed(() => (props.name ? catalog.presetUsedBy(props.name) : []))
66109
67110 async function save() {
68- if (problems.value.length) return
111+ if (!canSave.value) return
69112 saving.value = true
70113 saveError.value = null
71114 const items: Partial<Record<CatalogItemType, string[]>> = {}
@@ -79,7 +122,13 @@ async function save() {
79122 }
80123 try {
81124 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' })
125+ const n = usedBy.value.length
126+ toast.add({
127+ title: isNew.value ? 'Preset created' : 'Preset saved',
128+ description: !isNew.value && n ? `${form.name} · ${n} project${n > 1 ? 's' : ''} will show an update` : form.name,
129+ icon: 'i-lucide-check',
130+ color: 'success'
131+ })
83132 await catalog.refresh()
84133 original.value = snapshot()
85134 if (isNew.value || renaming.value) await navigateTo(`/catalog/presets/${encodeURIComponent(form.name)}`, { replace: true })
@@ -90,9 +139,17 @@ async function save() {
90139 }
91140 }
92141
142+function cancel() {
143+ navigateTo('/catalog/presets')
144+}
145+
93146 onBeforeRouteLeave(() => !dirty.value || window.confirm('Discard unsaved changes?'))
147+function beforeUnload(e: BeforeUnloadEvent) {
148+ if (dirty.value) e.preventDefault()
149+}
150+onMounted(() => window.addEventListener('beforeunload', beforeUnload))
151+onBeforeUnmount(() => window.removeEventListener('beforeunload', beforeUnload))
94152
95-const usedBy = computed(() => (props.name ? catalog.presetUsedBy(props.name) : []))
96153 const delOpen = ref(false)
97154 const deleting = ref(false)
98155 const delConflict = ref<string | null>(null)
@@ -121,105 +178,111 @@ function askDelete() {
121178 </script>
122179
123180 <template>
124- <UDashboardPanel id="preset-editor">
125- <template #header>
126- <UDashboardNavbar icon="i-lucide-layers">
127- <template #title>
128- <NuxtLink to="/catalog/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: '/catalog/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(' ')" />
181+ <SkPage wide>
182+ <CatalogHeader tab="presets" />
156183
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>
184+ <USkeleton v-if="!ready" class="h-72 w-full rounded-[20px]" />
185+ <SkNote v-else-if="notFound" tone="accent">
186+ <p class="m-0 font-medium">Preset not found</p>
187+ <p class="mt-1 mb-0 text-xs">It may have been renamed or deleted.</p>
188+ <template #aside><SkButton size="sm" label="Back to presets" to="/catalog/presets" /></template>
189+ </SkNote>
160190
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>
191+ <div v-else class="flex flex-col gap-3.5">
192+ <SkBreadcrumb :items="[{ label: 'Presets', to: '/catalog/presets' }, { label: isNew ? 'New preset' : name! }]" />
193+
194+ <div class="grid items-start gap-4 lg:grid-cols-[minmax(0,1fr)_minmax(260px,320px)]">
195+ <div class="flex min-w-0 flex-col gap-4">
196+ <SkNote v-if="saveError" tone="accent">
197+ <p class="m-0 font-medium">Save failed</p>
198+ <p class="mt-1 mb-0 text-xs whitespace-pre-wrap">{{ saveError }}</p>
199+ </SkNote>
200+
201+ <SkPanel class="grid gap-3.5 px-5 py-[18px] [grid-template-columns:repeat(auto-fit,minmax(220px,1fr))]">
202+ <CatalogField :label="renaming ? 'Name (renaming)' : 'Name'" :error="nameError">
203+ <UInput v-model="form.name" class="w-full" placeholder="e.g. nuxt-saas" :highlight="!!nameError" :color="nameError ? 'error' : undefined" />
204+ </CatalogField>
205+ <CatalogField label="Description">
206+ <UInput v-model="form.description" class="w-full" placeholder="What kind of project is this preset for?" />
207+ </CatalogField>
208+ </SkPanel>
166209
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>
210+ <SkPanel class="flex flex-col gap-2.5 px-5 py-4">
211+ <div class="flex items-baseline justify-between gap-3">
212+ <span class="font-semibold">Include presets</span>
213+ <span class="text-xs text-sk-faint">Their items are added to this preset</span>
187214 </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>
215+ <div v-if="includeChoices.length" class="flex flex-wrap gap-1.5">
216+ <SkChip
217+ v-for="c in includeChoices"
218+ :key="c.name"
219+ :active="form.includes.includes(c.name)"
220+ :count="c.count"
221+ :disabled="c.blocked"
222+ :title="c.blocked ? `${c.name} already includes this preset` : undefined"
223+ class="h-7 px-2.5 text-[13px]"
224+ @click="toggleInclude(c.name)"
225+ >{{ c.name }}</SkChip>
226+ </div>
227+ <p v-else class="m-0 text-sk-faint">No other preset to include yet.</p>
228+ </SkPanel>
229+
230+ <ItemPicker v-model="form.items" :items="catalog.items.value" :locked="locked" title="Items" />
201231 </div>
202- </div>
203232
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">Still enabled in these scopes. If you delete it anyway, they will report it as missing from the catalog.</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>
233+ <!-- Resolved preview -->
234+ <SkPanel class="flex flex-col lg:sticky lg:top-[76px]">
235+ <div class="flex flex-col gap-1 border-b border-sk-line2 px-[18px] py-4">
236+ <SkSectionLabel>Resolved preset</SkSectionLabel>
237+ <span class="text-[15px] font-semibold" :class="!form.name && 'text-sk-faint'">{{ form.name || 'Untitled preset' }}</span>
238+ <span class="text-sk-muted">{{ countText }}</span>
214239 </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)" />
240+
241+ <div v-if="resolved.missingPresets.length || resolved.cycles.length" class="flex flex-col gap-2 border-b border-sk-line2 px-[18px] py-3">
242+ <SkNote v-if="resolved.missingPresets.length" tone="accent">Unknown preset{{ resolved.missingPresets.length > 1 ? 's' : '' }}: <span class="font-mono text-xs">{{ resolved.missingPresets.join(', ') }}</span></SkNote>
243+ <SkNote v-if="resolved.cycles.length" tone="accent">Include cycle ignored: <span class="font-mono text-xs">{{ resolved.cycles.join(', ') }}</span></SkNote>
220244 </div>
221- </template>
222- </UModal>
223- </template>
224- </UDashboardPanel>
245+
246+ <div class="flex max-h-[calc(100vh-380px)] min-h-20 flex-col gap-0.5 overflow-auto px-[18px] py-2.5">
247+ <div v-for="r in resolvedRows" :key="`${r.type}/${r.name}`" class="flex items-baseline gap-2 py-1" :class="!r.own && 'text-sk-muted'">
248+ <NuxtLink
249+ v-if="!isMissing(r.type, r.name)"
250+ :to="`/catalog/${r.type}/${encodeURIComponent(r.name)}`"
251+ class="min-w-0 flex-1 truncate hover:underline"
252+ >{{ r.name }}</NuxtLink>
253+ <span v-else class="min-w-0 flex-1 truncate">{{ r.name }}</span>
254+ <span v-if="isMissing(r.type, r.name)" class="font-mono text-[11px] text-sk-accent-text">missing</span>
255+ <span v-if="r.own" class="font-mono text-[11px] text-sk-faint">{{ TYPE_TAG[r.type] }}</span>
256+ <span v-else class="font-mono text-[11px] text-sk-faint">via {{ r.from.join(', ') }}</span>
257+ </div>
258+ <span v-if="!resolvedRows.length" class="py-4 text-sk-faint">Pick items or include a preset.</span>
259+ </div>
260+
261+ <div v-if="!isNew" class="flex flex-col gap-1.5 border-t border-sk-line2 px-[18px] py-3">
262+ <SkSectionLabel>Used in</SkSectionLabel>
263+ <CatalogUsedBy :scopes="usedBy" empty="Not enabled in any project yet" />
264+ </div>
265+
266+ <div class="flex flex-col gap-2.5 border-t border-sk-line2 px-[18px] py-3">
267+ <span class="text-xs text-sk-faint">Projects choose automatic or on-demand mode, and can exclude single items.</span>
268+ <div class="flex items-center justify-end gap-2">
269+ <SkButton v-if="!isNew" variant="ghost" label="Delete" class="mr-auto" @click="askDelete" />
270+ <SkButton label="Cancel" @click="cancel" />
271+ <SkButton variant="primary" :label="isNew ? 'Create preset' : 'Save changes'" :loading="saving" :disabled="!canSave" @click="save" />
272+ </div>
273+ </div>
274+ </SkPanel>
275+ </div>
276+ </div>
277+
278+ <CatalogDeleteModal
279+ v-model:open="delOpen"
280+ kind="preset"
281+ :name="name ?? ''"
282+ :used-by="usedBy"
283+ :conflict="delConflict"
284+ :deleting="deleting"
285+ @confirm="doDelete"
286+ />
287+ </SkPage>
225288 </template>
@@ -1,12 +1,11 @@
1 <script setup lang="ts">1 <script setup lang="ts">
2 import type { CatalogItemType, Preset } from '@skilly/core'2 import type { CatalogItemType, Preset } from '@skilly/core'
3 3
4-/** Preset editor. `name` is undefined for a new preset. */4+/** Preset editor: name, description, included presets, items; resolved preview on the right. `name` is undefined for a new preset. */
5 const props = defineProps<{ name?: string }>()5 const props = defineProps<{ name?: string }>()
6 6
7 const { request } = useApi()7 const { request } = useApi()
8 const catalog = useCatalog()8 const catalog = useCatalog()
9-const scopeNames = useScopeNames()
10 const toast = useToast()9 const toast = useToast()
11 10
12 const isNew = computed(() => !props.name)11 const isNew = computed(() => !props.name)
@@ -39,33 +38,77 @@ async function init() {
39 original.value = snapshot()38 original.value = snapshot()
40 ready.value = true39 ready.value = true
41 }40 }
42-onMounted(init)41+onMounted(() => {
42+ init()
43+ catalog.refresh()
44+})
43 45
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 const renaming = computed(() => !isNew.value && form.name !== props.name)
47+const others = computed(() => catalog.presets.value.filter(p => p.name !== props.name))
46 48
47-const problems = computed(() => {49+/** Presets that (transitively) include `target`: including them here would create a cycle. */
48- const p: string[] = []50+function dependsOn(p: Preset, target: string, seen = new Set<string>()): boolean {
49- if (!NAME_RE.test(form.name)) p.push('Name must be kebab-case: lowercase letters, digits, "." "_" "-".')51+ if (seen.has(p.name)) return false
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.`)52+ seen.add(p.name)
51- return p53+ return p.includes.some(n => n === target || (others.value.find(x => x.name === n) ? dependsOn(others.value.find(x => x.name === n)!, target, seen) : false))
54+}
55+const includeChoices = computed(() => others.value
56+ .filter(p => p.name !== form.name)
57+ .map(p => ({
58+ name: p.name,
59+ count: resolveItems(catalog.presets.value, [p.name]).entries.length,
60+ blocked: !!props.name && dependsOn(p, props.name) && !form.includes.includes(p.name)
61+ })))
62+function toggleInclude(n: string) {
63+ form.includes = form.includes.includes(n) ? form.includes.filter(x => x !== n) : [...form.includes, n]
64+}
65+
66+const nameError = computed(() => {
67+ if (!form.name) return isNew.value && !dirty.value ? null : 'Give the preset a name.'
68+ if (!NAME_RE.test(form.name)) return 'Use kebab-case: lowercase letters, digits, "." "_" "-", starting with a letter or digit.'
69+ if ((isNew.value || renaming.value) && catalog.presets.value.some(x => x.name === form.name)) return 'A preset with this name already exists.'
70+ return null
52 })71 })
72+const canSave = computed(() => dirty.value && !!form.name && !nameError.value)
53 73
74+const selfName = computed(() => form.name || props.name || '(this preset)')
54 const resolved = computed(() => resolveItems(75 const resolved = computed(() => resolveItems(
55- catalog.presets.value.filter(p => p.name !== props.name),76+ others.value,
56 [],77 [],
57 {},78 {},
58- { name: form.name || props.name || '(this preset)', includes: form.includes, items: form.items }79+ { name: selfName.value, includes: form.includes, items: form.items }
59 ))80 ))
60-const resolvedByType = computed(() => CAT_TYPES.map(t => ({81+const inherited = computed(() => resolveItems(others.value, [], {}, { name: selfName.value, includes: form.includes, items: {} }))
61- type: t,82+/** Items provided by an included preset and not picked here: shown checked and locked in the picker. */
62- rows: resolved.value.entries.filter(e => e.type === t).sort((a, b) => a.name.localeCompare(b.name))83+const locked = computed(() => {
63-})).filter(g => g.rows.length))84+ const out: Record<string, string> = {}
64-const selfName = computed(() => form.name || props.name || '(this preset)')85+ for (const e of inherited.value.entries) {
65-const missingItems = (t: CatalogItemType, n: string) => !catalog.findItem(t, n)86+ if (form.items[e.type]?.includes(e.name)) continue
87+ out[itemKey(e.type, e.name)] = `via ${e.via.filter(v => v !== selfName.value).join(', ')}`
88+ }
89+ return out
90+})
91+
92+const resolvedRows = computed(() => [...resolved.value.entries]
93+ .sort((a, b) => CAT_ORDER.indexOf(a.type) - CAT_ORDER.indexOf(b.type) || a.name.localeCompare(b.name))
94+ .map(e => ({ ...e, from: e.via.filter(v => v !== selfName.value), own: e.via.includes(selfName.value) })))
95+const PLURAL: Record<CatalogItemType, [string, string]> = {
96+ skill: ['skill', 'skills'], rule: ['rule', 'rules'], mcp: ['MCP server', 'MCP servers'], hook: ['hook', 'hooks'], agent: ['subagent', 'subagents']
97+}
98+const countText = computed(() => {
99+ const n = resolvedRows.value.length
100+ if (!n) return 'Nothing yet'
101+ const parts = CAT_ORDER.map((t) => {
102+ const c = resolvedRows.value.filter(r => r.type === t).length
103+ return c ? `${c} ${PLURAL[t][c === 1 ? 0 : 1]}` : ''
104+ }).filter(Boolean)
105+ return `${n} item${n > 1 ? 's' : ''} · ${parts.join(', ')}`
106+})
107+const isMissing = (t: CatalogItemType, n: string) => !catalog.findItem(t, n)
108+const usedBy = computed(() => (props.name ? catalog.presetUsedBy(props.name) : []))
66 109
67 async function save() {110 async function save() {
68- if (problems.value.length) return111+ if (!canSave.value) return
69 saving.value = true112 saving.value = true
70 saveError.value = null113 saveError.value = null
71 const items: Partial<Record<CatalogItemType, string[]>> = {}114 const items: Partial<Record<CatalogItemType, string[]>> = {}
@@ -79,7 +122,13 @@ async function save() {
79 }122 }
80 try {123 try {
81 await request(`/api/catalog/presets/${encodeURIComponent(form.name)}`, { method: 'PUT', body, silent: true })124 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' })125+ const n = usedBy.value.length
126+ toast.add({
127+ title: isNew.value ? 'Preset created' : 'Preset saved',
128+ description: !isNew.value && n ? `${form.name} · ${n} project${n > 1 ? 's' : ''} will show an update` : form.name,
129+ icon: 'i-lucide-check',
130+ color: 'success'
131+ })
83 await catalog.refresh()132 await catalog.refresh()
84 original.value = snapshot()133 original.value = snapshot()
85 if (isNew.value || renaming.value) await navigateTo(`/catalog/presets/${encodeURIComponent(form.name)}`, { replace: true })134 if (isNew.value || renaming.value) await navigateTo(`/catalog/presets/${encodeURIComponent(form.name)}`, { replace: true })
@@ -90,9 +139,17 @@ async function save() {
90 }139 }
91 }140 }
92 141
142+function cancel() {
143+ navigateTo('/catalog/presets')
144+}
145+
93 onBeforeRouteLeave(() => !dirty.value || window.confirm('Discard unsaved changes?'))146 onBeforeRouteLeave(() => !dirty.value || window.confirm('Discard unsaved changes?'))
147+function beforeUnload(e: BeforeUnloadEvent) {
148+ if (dirty.value) e.preventDefault()
149+}
150+onMounted(() => window.addEventListener('beforeunload', beforeUnload))
151+onBeforeUnmount(() => window.removeEventListener('beforeunload', beforeUnload))
94 152
95-const usedBy = computed(() => (props.name ? catalog.presetUsedBy(props.name) : []))
96 const delOpen = ref(false)153 const delOpen = ref(false)
97 const deleting = ref(false)154 const deleting = ref(false)
98 const delConflict = ref<string | null>(null)155 const delConflict = ref<string | null>(null)
@@ -121,105 +178,111 @@ function askDelete() {
121 </script>178 </script>
122 179
123 <template>180 <template>
124- <UDashboardPanel id="preset-editor">181+ <SkPage wide>
125- <template #header>182+ <CatalogHeader tab="presets" />
126- <UDashboardNavbar icon="i-lucide-layers">
127- <template #title>
128- <NuxtLink to="/catalog/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: '/catalog/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 183
157- <UFormField label="Included presets" description="Their items are added to this preset (resolved recursively).">184+ <USkeleton v-if="!ready" class="h-72 w-full rounded-[20px]" />
158- <USelectMenu v-model="form.includes" :items="includeChoices" multiple placeholder="None" class="w-full" />185+ <SkNote v-else-if="notFound" tone="accent">
159- </UFormField>186+ <p class="m-0 font-medium">Preset not found</p>
187+ <p class="mt-1 mb-0 text-xs">It may have been renamed or deleted.</p>
188+ <template #aside><SkButton size="sm" label="Back to presets" to="/catalog/presets" /></template>
189+ </SkNote>
160 190
161- <div class="space-y-2">191+ <div v-else class="flex flex-col gap-3.5">
162- <h3 class="text-sm font-semibold">Items</h3>192+ <SkBreadcrumb :items="[{ label: 'Presets', to: '/catalog/presets' }, { label: isNew ? 'New preset' : name! }]" />
163- <ItemPicker v-model="form.items" :items="catalog.items.value" />193+
164- </div>194+ <div class="grid items-start gap-4 lg:grid-cols-[minmax(0,1fr)_minmax(260px,320px)]">
165- </div>195+ <div class="flex min-w-0 flex-col gap-4">
196+ <SkNote v-if="saveError" tone="accent">
197+ <p class="m-0 font-medium">Save failed</p>
198+ <p class="mt-1 mb-0 text-xs whitespace-pre-wrap">{{ saveError }}</p>
199+ </SkNote>
200+
201+ <SkPanel class="grid gap-3.5 px-5 py-[18px] [grid-template-columns:repeat(auto-fit,minmax(220px,1fr))]">
202+ <CatalogField :label="renaming ? 'Name (renaming)' : 'Name'" :error="nameError">
203+ <UInput v-model="form.name" class="w-full" placeholder="e.g. nuxt-saas" :highlight="!!nameError" :color="nameError ? 'error' : undefined" />
204+ </CatalogField>
205+ <CatalogField label="Description">
206+ <UInput v-model="form.description" class="w-full" placeholder="What kind of project is this preset for?" />
207+ </CatalogField>
208+ </SkPanel>
166 209
167- <div class="min-w-0 space-y-4">210+ <SkPanel class="flex flex-col gap-2.5 px-5 py-4">
168- <section class="space-y-2">211+ <div class="flex items-baseline justify-between gap-3">
169- <h3 class="text-sm font-semibold">Resolved content <span class="font-normal text-muted">({{ resolved.entries.length }} items)</span></h3>212+ <span class="font-semibold">Include presets</span>
170- <UAlert v-if="resolved.missingPresets.length" color="warning" variant="subtle" icon="i-lucide-triangle-alert" :title="`Unknown preset(s): ${resolved.missingPresets.join(', ')}`" />213+ <span class="text-xs text-sk-faint">Their items are added to this preset</span>
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>214 </div>
188- </section>215+ <div v-if="includeChoices.length" class="flex flex-wrap gap-1.5">
189-216+ <SkChip
190- <section v-if="!isNew" class="space-y-1.5">217+ v-for="c in includeChoices"
191- <h3 class="text-sm font-semibold">Used by</h3>218+ :key="c.name"
192- <ul v-if="usedBy.length" class="space-y-1 text-sm">219+ :active="form.includes.includes(c.name)"
193- <li v-for="s in usedBy" :key="s">220+ :count="c.count"
194- <NuxtLink :to="scopeLink(s)" class="flex items-center gap-1.5 text-primary hover:underline">221+ :disabled="c.blocked"
195- <UIcon :name="s === 'home' ? 'i-lucide-house' : 'i-lucide-folder'" class="size-4" />{{ scopeLabel(s, scopeNames) }}222+ :title="c.blocked ? `${c.name} already includes this preset` : undefined"
196- </NuxtLink>223+ class="h-7 px-2.5 text-[13px]"
197- </li>224+ @click="toggleInclude(c.name)"
198- </ul>225+ >{{ c.name }}</SkChip>
199- <p v-else class="text-xs text-muted">Not enabled in any scope.</p>226+ </div>
200- </section>227+ <p v-else class="m-0 text-sk-faint">No other preset to include yet.</p>
228+ </SkPanel>
229+
230+ <ItemPicker v-model="form.items" :items="catalog.items.value" :locked="locked" title="Items" />
201 </div>231 </div>
202- </div>
203 232
204- <UModal v-model:open="delOpen" :title="`Delete ${name}`" description="This removes the preset from the catalog.">233+ <!-- Resolved preview -->
205- <template #body>234+ <SkPanel class="flex flex-col lg:sticky lg:top-[76px]">
206- <div class="space-y-3 text-sm">235+ <div class="flex flex-col gap-1 border-b border-sk-line2 px-[18px] py-4">
207- <p>Items of the preset are kept in the catalog. Scopes that enabled it will report the preset as missing.</p>236+ <SkSectionLabel>Resolved preset</SkSectionLabel>
208- <UAlert v-if="delConflict || usedBy.length" color="warning" variant="subtle" icon="i-lucide-triangle-alert" :title="delConflict ? 'This preset is used' : 'Used by'">237+ <span class="text-[15px] font-semibold" :class="!form.name && 'text-sk-faint'">{{ form.name || 'Untitled preset' }}</span>
209- <template #description>238+ <span class="text-sk-muted">{{ countText }}</span>
210- <p v-if="delConflict" class="mb-1">Still enabled in these scopes. If you delete it anyway, they will report it as missing from the catalog.</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>239 </div>
215- </template>240+
216- <template #footer>241+ <div v-if="resolved.missingPresets.length || resolved.cycles.length" class="flex flex-col gap-2 border-b border-sk-line2 px-[18px] py-3">
217- <div class="flex w-full justify-end gap-2">242+ <SkNote v-if="resolved.missingPresets.length" tone="accent">Unknown preset{{ resolved.missingPresets.length > 1 ? 's' : '' }}: <span class="font-mono text-xs">{{ resolved.missingPresets.join(', ') }}</span></SkNote>
218- <UButton label="Cancel" color="neutral" variant="outline" @click="delOpen = false" />243+ <SkNote v-if="resolved.cycles.length" tone="accent">Include cycle ignored: <span class="font-mono text-xs">{{ resolved.cycles.join(', ') }}</span></SkNote>
219- <UButton :label="delConflict ? 'Delete anyway' : 'Delete'" color="error" :loading="deleting" @click="doDelete(!!delConflict)" />
220 </div>244 </div>
221- </template>245+
222- </UModal>246+ <div class="flex max-h-[calc(100vh-380px)] min-h-20 flex-col gap-0.5 overflow-auto px-[18px] py-2.5">
223- </template>247+ <div v-for="r in resolvedRows" :key="`${r.type}/${r.name}`" class="flex items-baseline gap-2 py-1" :class="!r.own && 'text-sk-muted'">
224- </UDashboardPanel>248+ <NuxtLink
249+ v-if="!isMissing(r.type, r.name)"
250+ :to="`/catalog/${r.type}/${encodeURIComponent(r.name)}`"
251+ class="min-w-0 flex-1 truncate hover:underline"
252+ >{{ r.name }}</NuxtLink>
253+ <span v-else class="min-w-0 flex-1 truncate">{{ r.name }}</span>
254+ <span v-if="isMissing(r.type, r.name)" class="font-mono text-[11px] text-sk-accent-text">missing</span>
255+ <span v-if="r.own" class="font-mono text-[11px] text-sk-faint">{{ TYPE_TAG[r.type] }}</span>
256+ <span v-else class="font-mono text-[11px] text-sk-faint">via {{ r.from.join(', ') }}</span>
257+ </div>
258+ <span v-if="!resolvedRows.length" class="py-4 text-sk-faint">Pick items or include a preset.</span>
259+ </div>
260+
261+ <div v-if="!isNew" class="flex flex-col gap-1.5 border-t border-sk-line2 px-[18px] py-3">
262+ <SkSectionLabel>Used in</SkSectionLabel>
263+ <CatalogUsedBy :scopes="usedBy" empty="Not enabled in any project yet" />
264+ </div>
265+
266+ <div class="flex flex-col gap-2.5 border-t border-sk-line2 px-[18px] py-3">
267+ <span class="text-xs text-sk-faint">Projects choose automatic or on-demand mode, and can exclude single items.</span>
268+ <div class="flex items-center justify-end gap-2">
269+ <SkButton v-if="!isNew" variant="ghost" label="Delete" class="mr-auto" @click="askDelete" />
270+ <SkButton label="Cancel" @click="cancel" />
271+ <SkButton variant="primary" :label="isNew ? 'Create preset' : 'Save changes'" :loading="saving" :disabled="!canSave" @click="save" />
272+ </div>
273+ </div>
274+ </SkPanel>
275+ </div>
276+ </div>
277+
278+ <CatalogDeleteModal
279+ v-model:open="delOpen"
280+ kind="preset"
281+ :name="name ?? ''"
282+ :used-by="usedBy"
283+ :conflict="delConflict"
284+ :deleting="deleting"
285+ @confirm="doDelete"
286+ />
287+ </SkPage>
225 </template>288 </template>
modified packages/ui/app/components/RemoveFromGlobalModal.vue +99 -56
@@ -1,50 +1,77 @@
11 <script setup lang="ts">
2-import type { InventoryItem, RemoveFromGlobalResult } from '@skilly/core'
2+import type { InventoryItem, ItemStatus, RemoveFromGlobalResult } from '@skilly/core'
33
4-const props = defineProps<{ items: InventoryItem[] }>()
4+/**
5+ * "Remove {name} from global" (mockup 7): three numbered steps adapted to the real API.
6+ * 1. Catalog: the item is imported unless an identical one is already there. A different item with the same name blocks the move.
7+ * 2. Enable in projects: written into the chosen projects (pre-checked with the ones that already use it).
8+ * 3. Remove from Home: files are deleted, symlinks only unlinked.
9+ * Only skills, rules and subagents can be moved (Home MCP servers and hooks are read-only in v1).
10+ */
11+const props = defineProps<{ items: InventoryItem[], statuses: Record<string, ItemStatus> }>()
512 const open = defineModel<boolean>('open', { default: false })
613 const emit = defineEmits<{ done: [] }>()
714
815 const { request } = useApi()
916 const { projects } = useProjects()
17+const catalog = useCatalog()
1018 const toast = useToast()
19+const { home } = useHomeInventory()
1120
1221 const chosen = ref<string[]>([])
13-const search = ref('')
1422 const busy = ref(false)
1523 const result = ref<RemoveFromGlobalResult | null>(null)
1624 const error = ref<string | null>(null)
1725
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-})
26+const live = computed(() => projects.value.filter(p => p.exists))
27+const stateOf = (i: InventoryItem) => props.statuses[i.id]?.state
28+const blocked = computed(() => props.items.filter(i => stateOf(i) === 'differs-from-catalog'))
29+const movable = computed(() => props.items.filter(i => stateOf(i) !== 'differs-from-catalog'))
30+const root = computed(() => home.value?.root ?? '')
31+
32+const FOLDERS: Record<string, string> = { skill: 'skills', rule: 'rules', agent: 'agents' }
33+const folderOf = (i: InventoryItem) => FOLDERS[i.type] ?? i.type
34+function catalogLine(i: InventoryItem): string {
35+ const st = stateOf(i)
36+ if (st === 'in-sync') return 'Already in the catalog. The catalog version will be used.'
37+ if (st === 'differs-from-catalog') return `A different ${i.type} named ${i.name} is already in the catalog. Add this one to the catalog with "overwrite" first, then move it.`
38+ return `It will be copied to the catalog as ${folderOf(i)}/${i.name}.`
39+}
2240
2341 watch(open, (v) => {
24- if (v) {
25- chosen.value = []
26- search.value = ''
27- result.value = null
28- error.value = null
42+ if (!v) return
43+ result.value = null
44+ error.value = null
45+ // Pre-check the projects that already use the item.
46+ const using = new Set<string>()
47+ for (const i of props.items) {
48+ const ref = props.statuses[i.id]?.catalogRef ?? { type: i.type, name: i.name }
49+ for (const id of catalog.usedBy(ref.type, ref.name)) if (id !== 'home') using.add(id)
2950 }
51+ chosen.value = live.value.filter(p => using.has(p.id)).map(p => p.id)
3052 })
3153
3254 function toggle(id: string, on: boolean | 'indeterminate') {
3355 chosen.value = on === true ? [...new Set([...chosen.value, id])] : chosen.value.filter(x => x !== id)
3456 }
3557
58+const title = computed(() => (props.items.length === 1 ? `Remove ${props.items[0]!.name} from global` : `Remove ${props.items.length} items from global`))
59+
3660 async function submit() {
3761 busy.value = true
3862 error.value = null
3963 try {
40- result.value = await request<RemoveFromGlobalResult>('/api/home/remove-from-global', {
64+ const res = await request<RemoveFromGlobalResult>('/api/home/remove-from-global', {
4165 method: 'POST',
42- body: { itemIds: props.items.map(i => i.id), enableIn: chosen.value },
66+ body: { itemIds: movable.value.map(i => i.id), enableIn: chosen.value },
4367 silent: true
4468 })
69+ result.value = res
70+ const first = props.items.find(i => i.id === res.removed[0])
4571 toast.add({
46- title: `${result.value.removed.length} removed from Home`,
47- color: result.value.errors.length ? 'warning' : 'success',
72+ title: res.removed.length === 1 && first ? `${first.name} removed from global` : `${res.removed.length} item${res.removed.length === 1 ? '' : 's'} removed from global`,
73+ description: chosen.value.length ? `enabled in ${chosen.value.length} project${chosen.value.length === 1 ? '' : 's'}` : 'available in the catalog only',
74+ color: res.errors.length ? 'warning' : 'success',
4875 icon: 'i-lucide-check'
4976 })
5077 emit('done')
@@ -59,55 +86,71 @@ const nameOf = (id: string) => props.items.find(i => i.id === id)?.name ?? id
5986 </script>
6087
6188 <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" />
89+ <SkModal v-model:open="open" :title="title" :description="result ? undefined : 'It will stop loading in every project, and only run where you enable it.'">
90+ <!-- Result -->
91+ <div v-if="result" class="flex flex-col gap-3">
92+ <SkNote v-if="result.removed.length" tone="ok">
93+ <span class="font-medium">{{ result.removed.length }} removed from Home</span>
94+ <span class="block font-mono text-xs text-sk-muted">{{ result.removed.map(nameOf).join(', ') }}</span>
95+ </SkNote>
96+ <SkNote v-if="result.imported.length" tone="info">
97+ <span class="font-medium">In the catalog</span>
98+ <span class="block font-mono text-xs text-sk-muted">{{ result.imported.map(i => `${i.type}/${i.name}`).join(', ') }}</span>
99+ </SkNote>
100+ <SkNote v-for="e in result.errors" :key="e.id" tone="accent"><span class="font-medium">{{ nameOf(e.id) }}</span>: {{ e.message }}</SkNote>
101+ </div>
102+
103+ <div v-else class="flex flex-col gap-5">
104+ <SkNote v-if="error" tone="accent"><span class="font-medium">Request failed.</span> {{ error }}</SkNote>
105+
106+ <!-- 1 Catalog -->
107+ <div class="flex gap-3">
108+ <span class="mt-px flex size-[22px] shrink-0 items-center justify-center rounded-full bg-sk-line2 font-mono text-[11px] text-sk-muted">1</span>
109+ <div class="flex min-w-0 flex-1 flex-col gap-1">
110+ <span class="font-medium">Catalog</span>
111+ <template v-if="items.length === 1"><span class="text-sk-muted" :class="blocked.length ? 'text-sk-accent-text' : ''">{{ catalogLine(items[0]!) }}</span></template>
112+ <ul v-else class="m-0 flex list-none flex-col gap-1 p-0">
113+ <li v-for="i in items" :key="i.id" class="text-xs" :class="stateOf(i) === 'differs-from-catalog' ? 'text-sk-accent-text' : 'text-sk-muted'">
114+ <span class="font-medium text-sk-text">{{ i.name }}</span> · {{ catalogLine(i) }}
87115 </li>
88116 </ul>
89117 </div>
118+ </div>
90119
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">
120+ <!-- 2 Projects -->
121+ <div class="flex gap-3">
122+ <span class="mt-px flex size-[22px] shrink-0 items-center justify-center rounded-full bg-sk-line2 font-mono text-[11px] text-sk-muted">2</span>
123+ <div class="flex min-w-0 flex-1 flex-col gap-1.5">
124+ <span class="font-medium">Enable in projects</span>
125+ <div v-if="live.length" class="max-h-48 overflow-y-auto rounded-[10px] border border-sk-line">
126+ <label v-for="p in live" :key="p.id" class="flex cursor-pointer items-center gap-2.5 border-t border-sk-line2 px-3 py-2 first:border-t-0 hover:bg-sk-hover">
96127 <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>
128+ <span class="shrink-0">{{ p.name }}</span>
129+ <span class="ml-auto min-w-0 truncate font-mono text-[11px] text-sk-faint" :title="p.path">{{ p.path }}</span>
130+ </label>
131+ </div>
132+ <p v-else class="m-0 text-sk-muted">No project yet: the item will only move to the catalog.</p>
133+ <p v-if="live.length && !chosen.length" class="m-0 text-xs text-sk-accent-text">No project selected: the item will only be available from the catalog.</p>
134+ </div>
135+ </div>
136+
137+ <!-- 3 Home -->
138+ <div class="flex gap-3">
139+ <span class="mt-px flex size-[22px] shrink-0 items-center justify-center rounded-full bg-sk-line2 font-mono text-[11px] text-sk-muted">3</span>
140+ <div class="flex min-w-0 flex-1 flex-col gap-1">
141+ <span class="font-medium">Remove from Home</span>
142+ <ul class="m-0 flex list-none flex-col gap-0.5 p-0 font-mono text-xs text-sk-muted">
143+ <li v-for="i in items" :key="i.id" class="break-all">
144+ {{ itemLocation(i, root, '~/') }} <span class="font-sans">will be {{ i.source.symlinkTarget ? 'unlinked (only the link, never its target)' : 'removed' }}</span>
99145 </li>
100146 </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>
103147 </div>
104148 </div>
105- </template>
149+ </div>
150+
106151 <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>
152+ <SkButton :label="result ? 'Close' : 'Cancel'" @click="open = false" />
153+ <SkButton v-if="!result" variant="primary" label="Remove from global" :loading="busy" :disabled="!movable.length" @click="submit" />
111154 </template>
112- </UModal>
155+ </SkModal>
113156 </template>
@@ -1,50 +1,77 @@
1 <script setup lang="ts">1 <script setup lang="ts">
2-import type { InventoryItem, RemoveFromGlobalResult } from '@skilly/core'2+import type { InventoryItem, ItemStatus, RemoveFromGlobalResult } from '@skilly/core'
3 3
4-const props = defineProps<{ items: InventoryItem[] }>()4+/**
5+ * "Remove {name} from global" (mockup 7): three numbered steps adapted to the real API.
6+ * 1. Catalog: the item is imported unless an identical one is already there. A different item with the same name blocks the move.
7+ * 2. Enable in projects: written into the chosen projects (pre-checked with the ones that already use it).
8+ * 3. Remove from Home: files are deleted, symlinks only unlinked.
9+ * Only skills, rules and subagents can be moved (Home MCP servers and hooks are read-only in v1).
10+ */
11+const props = defineProps<{ items: InventoryItem[], statuses: Record<string, ItemStatus> }>()
5 const open = defineModel<boolean>('open', { default: false })12 const open = defineModel<boolean>('open', { default: false })
6 const emit = defineEmits<{ done: [] }>()13 const emit = defineEmits<{ done: [] }>()
7 14
8 const { request } = useApi()15 const { request } = useApi()
9 const { projects } = useProjects()16 const { projects } = useProjects()
17+const catalog = useCatalog()
10 const toast = useToast()18 const toast = useToast()
19+const { home } = useHomeInventory()
11 20
12 const chosen = ref<string[]>([])21 const chosen = ref<string[]>([])
13-const search = ref('')
14 const busy = ref(false)22 const busy = ref(false)
15 const result = ref<RemoveFromGlobalResult | null>(null)23 const result = ref<RemoveFromGlobalResult | null>(null)
16 const error = ref<string | null>(null)24 const error = ref<string | null>(null)
17 25
18-const list = computed(() => {26+const live = computed(() => projects.value.filter(p => p.exists))
19- const q = search.value.trim().toLowerCase()27+const stateOf = (i: InventoryItem) => props.statuses[i.id]?.state
20- return projects.value.filter(p => p.exists && (!q || `${p.name} ${p.path}`.toLowerCase().includes(q)))28+const blocked = computed(() => props.items.filter(i => stateOf(i) === 'differs-from-catalog'))
21-})29+const movable = computed(() => props.items.filter(i => stateOf(i) !== 'differs-from-catalog'))
30+const root = computed(() => home.value?.root ?? '')
31+
32+const FOLDERS: Record<string, string> = { skill: 'skills', rule: 'rules', agent: 'agents' }
33+const folderOf = (i: InventoryItem) => FOLDERS[i.type] ?? i.type
34+function catalogLine(i: InventoryItem): string {
35+ const st = stateOf(i)
36+ if (st === 'in-sync') return 'Already in the catalog. The catalog version will be used.'
37+ if (st === 'differs-from-catalog') return `A different ${i.type} named ${i.name} is already in the catalog. Add this one to the catalog with "overwrite" first, then move it.`
38+ return `It will be copied to the catalog as ${folderOf(i)}/${i.name}.`
39+}
22 40
23 watch(open, (v) => {41 watch(open, (v) => {
24- if (v) {42+ if (!v) return
25- chosen.value = []43+ result.value = null
26- search.value = ''44+ error.value = null
27- result.value = null45+ // Pre-check the projects that already use the item.
28- error.value = null46+ const using = new Set<string>()
47+ for (const i of props.items) {
48+ const ref = props.statuses[i.id]?.catalogRef ?? { type: i.type, name: i.name }
49+ for (const id of catalog.usedBy(ref.type, ref.name)) if (id !== 'home') using.add(id)
29 }50 }
51+ chosen.value = live.value.filter(p => using.has(p.id)).map(p => p.id)
30 })52 })
31 53
32 function toggle(id: string, on: boolean | 'indeterminate') {54 function toggle(id: string, on: boolean | 'indeterminate') {
33 chosen.value = on === true ? [...new Set([...chosen.value, id])] : chosen.value.filter(x => x !== id)55 chosen.value = on === true ? [...new Set([...chosen.value, id])] : chosen.value.filter(x => x !== id)
34 }56 }
35 57
58+const title = computed(() => (props.items.length === 1 ? `Remove ${props.items[0]!.name} from global` : `Remove ${props.items.length} items from global`))
59+
36 async function submit() {60 async function submit() {
37 busy.value = true61 busy.value = true
38 error.value = null62 error.value = null
39 try {63 try {
40- result.value = await request<RemoveFromGlobalResult>('/api/home/remove-from-global', {64+ const res = await request<RemoveFromGlobalResult>('/api/home/remove-from-global', {
41 method: 'POST',65 method: 'POST',
42- body: { itemIds: props.items.map(i => i.id), enableIn: chosen.value },66+ body: { itemIds: movable.value.map(i => i.id), enableIn: chosen.value },
43 silent: true67 silent: true
44 })68 })
69+ result.value = res
70+ const first = props.items.find(i => i.id === res.removed[0])
45 toast.add({71 toast.add({
46- title: `${result.value.removed.length} removed from Home`,72+ title: res.removed.length === 1 && first ? `${first.name} removed from global` : `${res.removed.length} item${res.removed.length === 1 ? '' : 's'} removed from global`,
47- color: result.value.errors.length ? 'warning' : 'success',73+ description: chosen.value.length ? `enabled in ${chosen.value.length} project${chosen.value.length === 1 ? '' : 's'}` : 'available in the catalog only',
74+ color: res.errors.length ? 'warning' : 'success',
48 icon: 'i-lucide-check'75 icon: 'i-lucide-check'
49 })76 })
50 emit('done')77 emit('done')
@@ -59,55 +86,71 @@ const nameOf = (id: string) => props.items.find(i => i.id === id)?.name ?? id
59 </script>86 </script>
60 87
61 <template>88 <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' }">89+ <SkModal v-model:open="open" :title="title" :description="result ? undefined : 'It will stop loading in every project, and only run where you enable it.'">
63- <template #body>90+ <!-- Result -->
64- <div v-if="result" class="space-y-3 text-sm">91+ <div v-if="result" class="flex flex-col gap-3">
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(', ')" />92+ <SkNote v-if="result.removed.length" tone="ok">
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(', ')" />93+ <span class="font-medium">{{ result.removed.length }} removed from Home</span>
67- <UAlert v-if="result.errors.length" color="error" variant="subtle" icon="i-lucide-circle-alert" :title="`${result.errors.length} error(s)`">94+ <span class="block font-mono text-xs text-sk-muted">{{ result.removed.map(nameOf).join(', ') }}</span>
68- <template #description>95+ </SkNote>
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>96+ <SkNote v-if="result.imported.length" tone="info">
70- </template>97+ <span class="font-medium">In the catalog</span>
71- </UAlert>98+ <span class="block font-mono text-xs text-sk-muted">{{ result.imported.map(i => `${i.type}/${i.name}`).join(', ') }}</span>
72- </div>99+ </SkNote>
73- <div v-else class="space-y-4">100+ <SkNote v-for="e in result.errors" :key="e.id" tone="accent"><span class="font-medium">{{ nameOf(e.id) }}</span>: {{ e.message }}</SkNote>
74- <UAlert v-if="error" color="error" variant="subtle" icon="i-lucide-circle-alert" title="Request failed" :description="error" />101+ </div>
75- <ol class="list-decimal space-y-1 pl-5 text-sm text-muted">102+
76- <li>Each item is <strong class="text-default">copied to the catalog</strong> (unless an identical one is already there).</li>103+ <div v-else class="flex flex-col gap-5">
77- <li>It is <strong class="text-default">enabled in the projects you choose</strong> below and written into them.</li>104+ <SkNote v-if="error" tone="accent"><span class="font-medium">Request failed.</span> {{ error }}</SkNote>
78- <li>It is then <strong class="text-default">removed from Home</strong>. Symbolic links are only unlinked: their target is never deleted.</li>105+
79- </ol>106+ <!-- 1 Catalog -->
80-107+ <div class="flex gap-3">
81- <div>108+ <span class="mt-px flex size-[22px] shrink-0 items-center justify-center rounded-full bg-sk-line2 font-mono text-[11px] text-sk-muted">1</span>
82- <p class="mb-1 text-xs font-medium text-muted">Items ({{ items.length }})</p>109+ <div class="flex min-w-0 flex-1 flex-col gap-1">
83- <ul class="max-h-28 divide-y divide-default overflow-auto rounded-lg border border-default text-sm">110+ <span class="font-medium">Catalog</span>
84- <li v-for="i in items" :key="i.id" class="flex items-center gap-2 px-3 py-1">111+ <template v-if="items.length === 1"><span class="text-sk-muted" :class="blocked.length ? 'text-sk-accent-text' : ''">{{ catalogLine(items[0]!) }}</span></template>
85- <AgentBadge :agent="i.agent" compact /><span class="truncate font-medium">{{ i.name }}</span><span class="text-xs text-muted">{{ i.type }}</span>112+ <ul v-else class="m-0 flex list-none flex-col gap-1 p-0">
86- <UBadge v-if="i.source.symlinkTarget" label="symlink" size="sm" color="neutral" variant="subtle" class="ml-auto" />113+ <li v-for="i in items" :key="i.id" class="text-xs" :class="stateOf(i) === 'differs-from-catalog' ? 'text-sk-accent-text' : 'text-sk-muted'">
114+ <span class="font-medium text-sk-text">{{ i.name }}</span> · {{ catalogLine(i) }}
87 </li>115 </li>
88 </ul>116 </ul>
89 </div>117 </div>
118+ </div>
90 119
91- <div class="space-y-1.5">120+ <!-- 2 Projects -->
92- <p class="text-xs font-medium text-muted">Enable in these projects ({{ chosen.length }} selected)</p>121+ <div class="flex gap-3">
93- <UInput v-if="projects.length > 6" v-model="search" icon="i-lucide-search" size="sm" placeholder="Search projects..." class="w-full" />122+ <span class="mt-px flex size-[22px] shrink-0 items-center justify-center rounded-full bg-sk-line2 font-mono text-[11px] text-sk-muted">2</span>
94- <ul v-if="list.length" class="max-h-48 divide-y divide-default overflow-auto rounded-lg border border-default text-sm">123+ <div class="flex min-w-0 flex-1 flex-col gap-1.5">
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">124+ <span class="font-medium">Enable in projects</span>
125+ <div v-if="live.length" class="max-h-48 overflow-y-auto rounded-[10px] border border-sk-line">
126+ <label v-for="p in live" :key="p.id" class="flex cursor-pointer items-center gap-2.5 border-t border-sk-line2 px-3 py-2 first:border-t-0 hover:bg-sk-hover">
96 <UCheckbox :model-value="chosen.includes(p.id)" :aria-label="p.name" @update:model-value="(v) => toggle(p.id, v)" />127 <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>128+ <span class="shrink-0">{{ p.name }}</span>
98- <span class="truncate font-mono text-xs text-muted" :title="p.path">{{ p.path }}</span>129+ <span class="ml-auto min-w-0 truncate font-mono text-[11px] text-sk-faint" :title="p.path">{{ p.path }}</span>
130+ </label>
131+ </div>
132+ <p v-else class="m-0 text-sk-muted">No project yet: the item will only move to the catalog.</p>
133+ <p v-if="live.length && !chosen.length" class="m-0 text-xs text-sk-accent-text">No project selected: the item will only be available from the catalog.</p>
134+ </div>
135+ </div>
136+
137+ <!-- 3 Home -->
138+ <div class="flex gap-3">
139+ <span class="mt-px flex size-[22px] shrink-0 items-center justify-center rounded-full bg-sk-line2 font-mono text-[11px] text-sk-muted">3</span>
140+ <div class="flex min-w-0 flex-1 flex-col gap-1">
141+ <span class="font-medium">Remove from Home</span>
142+ <ul class="m-0 flex list-none flex-col gap-0.5 p-0 font-mono text-xs text-sk-muted">
143+ <li v-for="i in items" :key="i.id" class="break-all">
144+ {{ itemLocation(i, root, '~/') }} <span class="font-sans">will be {{ i.source.symlinkTarget ? 'unlinked (only the link, never its target)' : 'removed' }}</span>
99 </li>145 </li>
100 </ul>146 </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>147 </div>
104 </div>148 </div>
105- </template>149+ </div>
150+
106 <template #footer>151 <template #footer>
107- <div class="flex w-full justify-end gap-2">152+ <SkButton :label="result ? 'Close' : 'Cancel'" @click="open = false" />
108- <UButton :label="result ? 'Close' : 'Cancel'" color="neutral" variant="outline" @click="open = false" />153+ <SkButton v-if="!result" variant="primary" label="Remove from global" :loading="busy" :disabled="!movable.length" @click="submit" />
109- <UButton v-if="!result" label="Remove from global" icon="i-lucide-globe-lock" color="error" :loading="busy" @click="submit" />
110- </div>
111 </template>154 </template>
112- </UModal>155+ </SkModal>
113 </template>156 </template>
deleted packages/ui/app/components/ScopeConfigBar.vue +0 -69
deleted file mode 100644
@@ -1,69 +0,0 @@
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 ? '/catalog/presets/new' : '/catalog'" class="text-primary hover:underline">{{ catalogExists ? 'Create one' : 'Create the catalog' }}</NuxtLink>
66- </span>
67- </div>
68- </div>
69-</template>
deleted file mode 100644
@@ -1,69 +0,0 @@
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 ? '/catalog/presets/new' : '/catalog'" class="text-primary hover:underline">{{ catalogExists ? 'Create one' : 'Create the catalog' }}</NuxtLink>
66- </span>
67- </div>
68- </div>
69-</template>
deleted packages/ui/app/components/ScopeConfigPanel.vue +0 -282
deleted file mode 100644
@@ -1,282 +0,0 @@
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>
deleted file mode 100644
@@ -1,282 +0,0 @@
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>
deleted packages/ui/app/components/ScopeView.vue +0 -483
deleted file mode 100644
@@ -1,483 +0,0 @@
1-<script setup lang="ts">
2-import type { AgentId, CatalogRef, InventoryItem, ItemType, ScopeConfig, ScopeState } from '@skilly/core'
3-
4-const props = defineProps<{ kind: 'home' | 'project', projectId?: string }>()
5-
6-const { request } = useApi()
7-const { projects, remove, refresh: refreshProjects } = useProjects()
8-const catalog = useCatalog()
9-const toast = useToast()
10-const router = useRouter()
11-
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)
17-const loading = ref(true)
18-const error = ref<string | null>(null)
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-
24-const agentFilter = ref<'all' | AgentId>('all')
25-const search = ref('')
26-const tab = ref<ItemType | 'config'>('skill')
27-const selected = ref<InventoryItem | null>(null)
28-const detailOpen = ref(false)
29-const removeOpen = ref(false)
30-const removing = ref(false)
31-const errorsOpen = ref(false)
32-
33-const project = computed(() => (props.kind === 'project' ? projects.value.find(p => p.id === props.projectId) : undefined))
34-const title = computed(() => (props.kind === 'home' ? 'Home' : project.value?.name ?? props.projectId ?? 'Project'))
35-const rootPath = computed(() => inventory.value?.root ?? project.value?.path ?? '')
36-const folderMissing = computed(() => props.kind === 'project' && project.value && !project.value.exists)
37-
38-async function load(showLoading = true) {
39- if (showLoading) loading.value = true
40- try {
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
43- error.value = null
44- } catch (e) {
45- error.value = (e as Error).message
46- // The folder may have disappeared: refresh the summaries so `exists` is up to date.
47- if (props.kind === 'project') await refreshProjects()
48- } finally {
49- loading.value = false
50- }
51-}
52-
53-async function rescan() {
54- await load(false)
55- if (!error.value) toast.add({ title: 'Rescan complete', icon: 'i-lucide-check', color: 'success' })
56-}
57-
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 */
105-
106-const matches = (item: InventoryItem, q: string) => {
107- const d = item.details
108- const hay = [
109- item.name, item.description, item.source.path, item.source.plugin,
110- d.type === 'mcp' ? `${d.command ?? ''} ${d.url ?? ''} ${d.transport}` : '',
111- d.type === 'hook' ? `${d.event} ${d.matcher ?? ''} ${d.command ?? ''}` : '',
112- d.type === 'agent' ? d.model : ''
113- ].join(' ').toLowerCase()
114- return hay.includes(q)
115-}
116-
117-const filtered = computed(() => {
118- const q = search.value.trim().toLowerCase()
119- return (inventory.value?.items ?? []).filter(i =>
120- (agentFilter.value === 'all' || i.agent === agentFilter.value) && (!q || matches(i, q))
121- )
122-})
123-
124-const byType = computed(() => {
125- const m = Object.fromEntries(TYPE_ORDER.map(t => [t, [] as InventoryItem[]])) as Record<ItemType, InventoryItem[]>
126- for (const i of filtered.value) m[i.type].push(i)
127- // Sort by name, then agent; hooks and instruction files keep their scan order (it is meaningful).
128- for (const t of ['skill', 'rule', 'mcp', 'agent'] as const) {
129- m[t].sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }) || a.agent.localeCompare(b.agent))
130- }
131- return m
132-})
133-
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-])
147-
148-const agentOptions = [
149- { label: 'All', value: 'all' },
150- { label: 'Claude Code', value: 'claude' },
151- { label: 'Codex', value: 'codex' }
152-]
153-
154-function open(item: InventoryItem) {
155- selected.value = item
156- detailOpen.value = true
157-}
158-
159-async function copyPath() {
160- try {
161- await navigator.clipboard.writeText(rootPath.value)
162- toast.add({ title: 'Path copied', icon: 'i-lucide-check', color: 'success' })
163- } catch {
164- toast.add({ title: 'Could not copy', color: 'error' })
165- }
166-}
167-
168-async function confirmRemove() {
169- if (!props.projectId) return
170- removing.value = true
171- try {
172- await remove(props.projectId)
173- removeOpen.value = false
174- toast.add({ title: 'Project removed from skilly', description: 'No file was deleted.', icon: 'i-lucide-check', color: 'success' })
175- await router.push('/')
176- } catch {
177- // toast already shown by useApi
178- } finally {
179- removing.value = false
180- }
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']
227-</script>
228-
229-<template>
230- <UDashboardPanel :id="`scope-${kind}`">
231- <template #header>
232- <UDashboardNavbar :icon="kind === 'home' ? 'i-lucide-house' : 'i-lucide-folder'">
233- <template #title>
234- <!-- The default title is a flex box, which cannot show an ellipsis for long project names -->
235- <span class="truncate" :title="title">{{ title }}</span>
236- </template>
237- <template #right>
238- <UButton
239- icon="i-lucide-refresh-cw"
240- label="Rescan"
241- color="neutral"
242- variant="outline"
243- :loading="loading && !!inventory"
244- @click="rescan"
245- />
246- <UButton
247- v-if="kind === 'project'"
248- icon="i-lucide-trash-2"
249- label="Remove from skilly"
250- color="error"
251- variant="soft"
252- @click="removeOpen = true"
253- />
254- </template>
255- </UDashboardNavbar>
256- </template>
257-
258- <template #body>
259- <!-- Loading -->
260- <div v-if="loading && !inventory" class="space-y-3">
261- <USkeleton class="h-6 w-1/2" />
262- <USkeleton class="h-10 w-full" />
263- <USkeleton v-for="i in 5" :key="i" class="h-12 w-full" />
264- </div>
265-
266- <!-- Error -->
267- <div v-else-if="error && !inventory" class="mx-auto max-w-lg py-12">
268- <UAlert
269- v-if="folderMissing"
270- color="warning"
271- variant="subtle"
272- icon="i-lucide-folder-x"
273- title="This folder no longer exists on disk"
274- :description="`${rootPath} was moved or deleted. You can remove the project from skilly, or retry once the folder is back.`"
275- :actions="[
276- { label: 'Retry', color: 'neutral', variant: 'outline', onClick: () => load() },
277- { label: 'Remove from skilly', color: 'error', variant: 'soft', onClick: () => { removeOpen = true } }
278- ]"
279- />
280- <UAlert
281- v-else
282- color="error"
283- variant="subtle"
284- icon="i-lucide-circle-alert"
285- title="Could not load this scope"
286- :description="error"
287- :actions="[{ label: 'Retry', color: 'neutral', variant: 'outline', onClick: () => load() }]"
288- />
289- </div>
290-
291- <div v-else-if="inventory" class="flex flex-col gap-4">
292- <!-- Header info -->
293- <div class="flex flex-wrap items-center gap-x-4 gap-y-2 text-sm">
294- <div class="flex min-w-0 items-center gap-1">
295- <span class="truncate font-mono text-xs text-muted" :title="rootPath">{{ rootPath }}</span>
296- <UTooltip text="Copy path">
297- <UButton icon="i-lucide-copy" size="xs" color="neutral" variant="ghost" aria-label="Copy path" @click="copyPath" />
298- </UTooltip>
299- </div>
300- <div class="flex items-center gap-1.5">
301- <AgentBadge v-for="a in inventory.agentsDetected" :key="a" :agent="a" />
302- <span v-if="!inventory.agentsDetected.length" class="text-muted">No agent detected</span>
303- </div>
304- <span class="text-xs text-muted">Scanned {{ formatDate(inventory.scannedAt) }}</span>
305- </div>
306-
307- <UAlert
308- v-if="folderMissing"
309- color="warning"
310- variant="subtle"
311- icon="i-lucide-triangle-alert"
312- title="This folder no longer exists on disk"
313- description="The inventory below may be empty or outdated. You can remove the project from skilly."
314- />
315-
316- <!-- Scan errors -->
317- <UAlert
318- v-if="inventory.errors.length"
319- color="warning"
320- variant="subtle"
321- icon="i-lucide-triangle-alert"
322- :title="`${inventory.errors.length} scan error${inventory.errors.length > 1 ? 's' : ''}`"
323- >
324- <template #description>
325- <UCollapsible v-model:open="errorsOpen">
326- <UButton
327- :label="errorsOpen ? 'Hide details' : 'Show details'"
328- :trailing-icon="errorsOpen ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'"
329- color="neutral"
330- variant="link"
331- size="xs"
332- class="px-0"
333- />
334- <template #content>
335- <ul class="mt-2 space-y-1.5 text-xs">
336- <li v-for="(e, i) in inventory.errors" :key="i">
337- <AgentBadge v-if="e.agent" :agent="e.agent" compact class="mr-1" />
338- <span class="font-mono break-all" :title="e.path">{{ relativeTo(rootPath, e.path) }}</span>
339- <span class="block text-muted">{{ e.message }}</span>
340- </li>
341- </ul>
342- </template>
343- </UCollapsible>
344- </template>
345- </UAlert>
346-
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" />
361- </div>
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-
373- <!-- Type tabs -->
374- <UTabs v-model="tab" :items="tabs" :content="false" variant="link" class="w-full" :ui="{ list: 'overflow-x-auto overflow-y-hidden [scrollbar-width:thin]', trigger: 'shrink-0 max-xl:px-1.5 max-xl:gap-1 max-xl:text-xs' }" />
375-
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>
459- </div>
460- </template>
461- </UDashboardPanel>
462-
463- <ItemDetail v-model:open="detailOpen" :item="selected" />
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-
469- <UModal v-model:open="removeOpen" title="Remove project from skilly" description="This only forgets the project in skilly.">
470- <template #body>
471- <p class="text-sm">
472- <strong>{{ title }}</strong> will disappear from the project list. <strong>No file is deleted</strong>:
473- the folder and its agent configuration stay untouched. You can add it again at any time.
474- </p>
475- </template>
476- <template #footer>
477- <div class="flex w-full justify-end gap-2">
478- <UButton label="Cancel" color="neutral" variant="outline" @click="removeOpen = false" />
479- <UButton label="Remove from skilly" color="error" :loading="removing" @click="confirmRemove" />
480- </div>
481- </template>
482- </UModal>
483-</template>
deleted file mode 100644
@@ -1,483 +0,0 @@
1-<script setup lang="ts">
2-import type { AgentId, CatalogRef, InventoryItem, ItemType, ScopeConfig, ScopeState } from '@skilly/core'
3-
4-const props = defineProps<{ kind: 'home' | 'project', projectId?: string }>()
5-
6-const { request } = useApi()
7-const { projects, remove, refresh: refreshProjects } = useProjects()
8-const catalog = useCatalog()
9-const toast = useToast()
10-const router = useRouter()
11-
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)
17-const loading = ref(true)
18-const error = ref<string | null>(null)
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-
24-const agentFilter = ref<'all' | AgentId>('all')
25-const search = ref('')
26-const tab = ref<ItemType | 'config'>('skill')
27-const selected = ref<InventoryItem | null>(null)
28-const detailOpen = ref(false)
29-const removeOpen = ref(false)
30-const removing = ref(false)
31-const errorsOpen = ref(false)
32-
33-const project = computed(() => (props.kind === 'project' ? projects.value.find(p => p.id === props.projectId) : undefined))
34-const title = computed(() => (props.kind === 'home' ? 'Home' : project.value?.name ?? props.projectId ?? 'Project'))
35-const rootPath = computed(() => inventory.value?.root ?? project.value?.path ?? '')
36-const folderMissing = computed(() => props.kind === 'project' && project.value && !project.value.exists)
37-
38-async function load(showLoading = true) {
39- if (showLoading) loading.value = true
40- try {
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
43- error.value = null
44- } catch (e) {
45- error.value = (e as Error).message
46- // The folder may have disappeared: refresh the summaries so `exists` is up to date.
47- if (props.kind === 'project') await refreshProjects()
48- } finally {
49- loading.value = false
50- }
51-}
52-
53-async function rescan() {
54- await load(false)
55- if (!error.value) toast.add({ title: 'Rescan complete', icon: 'i-lucide-check', color: 'success' })
56-}
57-
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 */
105-
106-const matches = (item: InventoryItem, q: string) => {
107- const d = item.details
108- const hay = [
109- item.name, item.description, item.source.path, item.source.plugin,
110- d.type === 'mcp' ? `${d.command ?? ''} ${d.url ?? ''} ${d.transport}` : '',
111- d.type === 'hook' ? `${d.event} ${d.matcher ?? ''} ${d.command ?? ''}` : '',
112- d.type === 'agent' ? d.model : ''
113- ].join(' ').toLowerCase()
114- return hay.includes(q)
115-}
116-
117-const filtered = computed(() => {
118- const q = search.value.trim().toLowerCase()
119- return (inventory.value?.items ?? []).filter(i =>
120- (agentFilter.value === 'all' || i.agent === agentFilter.value) && (!q || matches(i, q))
121- )
122-})
123-
124-const byType = computed(() => {
125- const m = Object.fromEntries(TYPE_ORDER.map(t => [t, [] as InventoryItem[]])) as Record<ItemType, InventoryItem[]>
126- for (const i of filtered.value) m[i.type].push(i)
127- // Sort by name, then agent; hooks and instruction files keep their scan order (it is meaningful).
128- for (const t of ['skill', 'rule', 'mcp', 'agent'] as const) {
129- m[t].sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }) || a.agent.localeCompare(b.agent))
130- }
131- return m
132-})
133-
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-])
147-
148-const agentOptions = [
149- { label: 'All', value: 'all' },
150- { label: 'Claude Code', value: 'claude' },
151- { label: 'Codex', value: 'codex' }
152-]
153-
154-function open(item: InventoryItem) {
155- selected.value = item
156- detailOpen.value = true
157-}
158-
159-async function copyPath() {
160- try {
161- await navigator.clipboard.writeText(rootPath.value)
162- toast.add({ title: 'Path copied', icon: 'i-lucide-check', color: 'success' })
163- } catch {
164- toast.add({ title: 'Could not copy', color: 'error' })
165- }
166-}
167-
168-async function confirmRemove() {
169- if (!props.projectId) return
170- removing.value = true
171- try {
172- await remove(props.projectId)
173- removeOpen.value = false
174- toast.add({ title: 'Project removed from skilly', description: 'No file was deleted.', icon: 'i-lucide-check', color: 'success' })
175- await router.push('/')
176- } catch {
177- // toast already shown by useApi
178- } finally {
179- removing.value = false
180- }
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']
227-</script>
228-
229-<template>
230- <UDashboardPanel :id="`scope-${kind}`">
231- <template #header>
232- <UDashboardNavbar :icon="kind === 'home' ? 'i-lucide-house' : 'i-lucide-folder'">
233- <template #title>
234- <!-- The default title is a flex box, which cannot show an ellipsis for long project names -->
235- <span class="truncate" :title="title">{{ title }}</span>
236- </template>
237- <template #right>
238- <UButton
239- icon="i-lucide-refresh-cw"
240- label="Rescan"
241- color="neutral"
242- variant="outline"
243- :loading="loading && !!inventory"
244- @click="rescan"
245- />
246- <UButton
247- v-if="kind === 'project'"
248- icon="i-lucide-trash-2"
249- label="Remove from skilly"
250- color="error"
251- variant="soft"
252- @click="removeOpen = true"
253- />
254- </template>
255- </UDashboardNavbar>
256- </template>
257-
258- <template #body>
259- <!-- Loading -->
260- <div v-if="loading && !inventory" class="space-y-3">
261- <USkeleton class="h-6 w-1/2" />
262- <USkeleton class="h-10 w-full" />
263- <USkeleton v-for="i in 5" :key="i" class="h-12 w-full" />
264- </div>
265-
266- <!-- Error -->
267- <div v-else-if="error && !inventory" class="mx-auto max-w-lg py-12">
268- <UAlert
269- v-if="folderMissing"
270- color="warning"
271- variant="subtle"
272- icon="i-lucide-folder-x"
273- title="This folder no longer exists on disk"
274- :description="`${rootPath} was moved or deleted. You can remove the project from skilly, or retry once the folder is back.`"
275- :actions="[
276- { label: 'Retry', color: 'neutral', variant: 'outline', onClick: () => load() },
277- { label: 'Remove from skilly', color: 'error', variant: 'soft', onClick: () => { removeOpen = true } }
278- ]"
279- />
280- <UAlert
281- v-else
282- color="error"
283- variant="subtle"
284- icon="i-lucide-circle-alert"
285- title="Could not load this scope"
286- :description="error"
287- :actions="[{ label: 'Retry', color: 'neutral', variant: 'outline', onClick: () => load() }]"
288- />
289- </div>
290-
291- <div v-else-if="inventory" class="flex flex-col gap-4">
292- <!-- Header info -->
293- <div class="flex flex-wrap items-center gap-x-4 gap-y-2 text-sm">
294- <div class="flex min-w-0 items-center gap-1">
295- <span class="truncate font-mono text-xs text-muted" :title="rootPath">{{ rootPath }}</span>
296- <UTooltip text="Copy path">
297- <UButton icon="i-lucide-copy" size="xs" color="neutral" variant="ghost" aria-label="Copy path" @click="copyPath" />
298- </UTooltip>
299- </div>
300- <div class="flex items-center gap-1.5">
301- <AgentBadge v-for="a in inventory.agentsDetected" :key="a" :agent="a" />
302- <span v-if="!inventory.agentsDetected.length" class="text-muted">No agent detected</span>
303- </div>
304- <span class="text-xs text-muted">Scanned {{ formatDate(inventory.scannedAt) }}</span>
305- </div>
306-
307- <UAlert
308- v-if="folderMissing"
309- color="warning"
310- variant="subtle"
311- icon="i-lucide-triangle-alert"
312- title="This folder no longer exists on disk"
313- description="The inventory below may be empty or outdated. You can remove the project from skilly."
314- />
315-
316- <!-- Scan errors -->
317- <UAlert
318- v-if="inventory.errors.length"
319- color="warning"
320- variant="subtle"
321- icon="i-lucide-triangle-alert"
322- :title="`${inventory.errors.length} scan error${inventory.errors.length > 1 ? 's' : ''}`"
323- >
324- <template #description>
325- <UCollapsible v-model:open="errorsOpen">
326- <UButton
327- :label="errorsOpen ? 'Hide details' : 'Show details'"
328- :trailing-icon="errorsOpen ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'"
329- color="neutral"
330- variant="link"
331- size="xs"
332- class="px-0"
333- />
334- <template #content>
335- <ul class="mt-2 space-y-1.5 text-xs">
336- <li v-for="(e, i) in inventory.errors" :key="i">
337- <AgentBadge v-if="e.agent" :agent="e.agent" compact class="mr-1" />
338- <span class="font-mono break-all" :title="e.path">{{ relativeTo(rootPath, e.path) }}</span>
339- <span class="block text-muted">{{ e.message }}</span>
340- </li>
341- </ul>
342- </template>
343- </UCollapsible>
344- </template>
345- </UAlert>
346-
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" />
361- </div>
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-
373- <!-- Type tabs -->
374- <UTabs v-model="tab" :items="tabs" :content="false" variant="link" class="w-full" :ui="{ list: 'overflow-x-auto overflow-y-hidden [scrollbar-width:thin]', trigger: 'shrink-0 max-xl:px-1.5 max-xl:gap-1 max-xl:text-xs' }" />
375-
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>
459- </div>
460- </template>
461- </UDashboardPanel>
462-
463- <ItemDetail v-model:open="detailOpen" :item="selected" />
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-
469- <UModal v-model:open="removeOpen" title="Remove project from skilly" description="This only forgets the project in skilly.">
470- <template #body>
471- <p class="text-sm">
472- <strong>{{ title }}</strong> will disappear from the project list. <strong>No file is deleted</strong>:
473- the folder and its agent configuration stay untouched. You can add it again at any time.
474- </p>
475- </template>
476- <template #footer>
477- <div class="flex w-full justify-end gap-2">
478- <UButton label="Cancel" color="neutral" variant="outline" @click="removeOpen = false" />
479- <UButton label="Remove from skilly" color="error" :loading="removing" @click="confirmRemove" />
480- </div>
481- </template>
482- </UModal>
483-</template>
added packages/ui/app/components/home/HomeInventory.vue +95 -0
new file mode 100644
@@ -0,0 +1,95 @@
1+<script setup lang="ts">
2+import type { InventoryItem } from '@skilly/core'
3+import type { ProjectSpecific } from '../../utils/inventoryView'
4+
5+/**
6+ * Home "Inventory" tab (mockup 6): "1 Loaded everywhere" hero tile and per-directory counts on the left, the
7+ * inventory tray on the right, then the "project-specific" suggestion panel below.
8+ */
9+const scope = useScopeContext()
10+const catalog = useCatalog()
11+const { projects } = useProjects()
12+
13+const inventory = computed(() => scope.inventory.value!)
14+const items = computed(() => inventory.value.items)
15+const everywhere = computed(() => items.value.filter(i => i.type !== 'instructions'))
16+
17+const directories = computed(() => {
18+ const counts = new Map<string, number>()
19+ for (const i of everywhere.value) {
20+ const d = homeDirOf(i, inventory.value.root)
21+ counts.set(d, (counts.get(d) ?? 0) + 1)
22+ }
23+ return [...counts.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
24+})
25+
26+/** Items that look project-specific (brief 4.5), by inventory item id. */
27+const flagged = computed(() => {
28+ const m = new Map<string, ProjectSpecific>()
29+ for (const i of items.value) {
30+ const p = projectSpecific(i, scope.statuses.value[i.id], catalog.usage.value, projects.value)
31+ if (p && canMoveOutOfHome(i, scope.statuses.value[i.id])) m.set(i.id, p)
32+ }
33+ return m
34+})
35+
36+const reviewOnly = ref(false)
37+const onlyIds = computed(() => (reviewOnly.value ? new Set(flagged.value.keys()) : null))
38+watch(() => flagged.value.size, (n) => { if (!n) reviewOnly.value = false })
39+
40+const errorsOpen = ref(false)
41+function importOne(i: InventoryItem) { scope.askImport([i]) }
42+</script>
43+
44+<template>
45+ <div class="flex flex-col gap-4">
46+ <SkPanel hero>
47+ <div class="grid lg:grid-cols-[280px_minmax(0,1fr)]">
48+ <div class="flex flex-col gap-4 p-[22px] lg:border-r lg:border-sk-line2">
49+ <SkStepHeader step="1" title="Loaded everywhere" />
50+ <SkDitherTile variant="hero" :height="240" :value="everywhere.length" :caption="everywhere.length === 1 ? 'item in every project' : 'items in every project'" />
51+ <ul class="m-0 flex list-none flex-col gap-1.5 p-0 font-mono text-xs">
52+ <li v-for="[dir, n] in directories" :key="dir" class="flex justify-between text-sk-muted">
53+ <span>{{ dir }}</span><span class="text-sk-faint">{{ n }}</span>
54+ </li>
55+ <li v-if="!directories.length" class="text-sk-faint">Nothing is loaded from Home.</li>
56+ </ul>
57+ </div>
58+
59+ <div class="min-w-0 p-[22px]">
60+ <ScopeInventoryList
61+ :items="items"
62+ :statuses="scope.statuses.value"
63+ :root="inventory.root"
64+ mode="home"
65+ :flagged="flagged"
66+ :only-ids="onlyIds"
67+ @select="scope.openItem"
68+ @move="(i: InventoryItem) => scope.askMove([i])"
69+ @import="importOne"
70+ >
71+ <template #top>
72+ <SkNote v-if="inventory.errors.length" tone="accent" class="mb-1">
73+ <button type="button" class="cursor-pointer border-0 bg-transparent p-0 text-left font-semibold text-inherit" @click="errorsOpen = !errorsOpen">
74+ {{ inventory.errors.length }} item{{ inventory.errors.length > 1 ? 's' : '' }} could not be read
75+ <span class="font-normal opacity-80">· {{ errorsOpen ? 'hide' : 'show' }} details</span>
76+ </button>
77+ <ScopeScanErrors v-if="errorsOpen" :errors="inventory.errors" :root="inventory.root" />
78+ </SkNote>
79+ </template>
80+ </ScopeInventoryList>
81+ </div>
82+ </div>
83+ </SkPanel>
84+
85+ <HomeSuggestions
86+ v-if="flagged.size"
87+ :items="items.filter(i => flagged.has(i.id))"
88+ :flagged="flagged"
89+ :statuses="scope.statuses.value"
90+ :review="reviewOnly"
91+ @toggle-review="reviewOnly = !reviewOnly"
92+ @move="(i: InventoryItem) => scope.askMove([i])"
93+ />
94+ </div>
95+</template>
new file mode 100644
@@ -0,0 +1,95 @@
1+<script setup lang="ts">
2+import type { InventoryItem } from '@skilly/core'
3+import type { ProjectSpecific } from '../../utils/inventoryView'
4+
5+/**
6+ * Home "Inventory" tab (mockup 6): "1 Loaded everywhere" hero tile and per-directory counts on the left, the
7+ * inventory tray on the right, then the "project-specific" suggestion panel below.
8+ */
9+const scope = useScopeContext()
10+const catalog = useCatalog()
11+const { projects } = useProjects()
12+
13+const inventory = computed(() => scope.inventory.value!)
14+const items = computed(() => inventory.value.items)
15+const everywhere = computed(() => items.value.filter(i => i.type !== 'instructions'))
16+
17+const directories = computed(() => {
18+ const counts = new Map<string, number>()
19+ for (const i of everywhere.value) {
20+ const d = homeDirOf(i, inventory.value.root)
21+ counts.set(d, (counts.get(d) ?? 0) + 1)
22+ }
23+ return [...counts.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
24+})
25+
26+/** Items that look project-specific (brief 4.5), by inventory item id. */
27+const flagged = computed(() => {
28+ const m = new Map<string, ProjectSpecific>()
29+ for (const i of items.value) {
30+ const p = projectSpecific(i, scope.statuses.value[i.id], catalog.usage.value, projects.value)
31+ if (p && canMoveOutOfHome(i, scope.statuses.value[i.id])) m.set(i.id, p)
32+ }
33+ return m
34+})
35+
36+const reviewOnly = ref(false)
37+const onlyIds = computed(() => (reviewOnly.value ? new Set(flagged.value.keys()) : null))
38+watch(() => flagged.value.size, (n) => { if (!n) reviewOnly.value = false })
39+
40+const errorsOpen = ref(false)
41+function importOne(i: InventoryItem) { scope.askImport([i]) }
42+</script>
43+
44+<template>
45+ <div class="flex flex-col gap-4">
46+ <SkPanel hero>
47+ <div class="grid lg:grid-cols-[280px_minmax(0,1fr)]">
48+ <div class="flex flex-col gap-4 p-[22px] lg:border-r lg:border-sk-line2">
49+ <SkStepHeader step="1" title="Loaded everywhere" />
50+ <SkDitherTile variant="hero" :height="240" :value="everywhere.length" :caption="everywhere.length === 1 ? 'item in every project' : 'items in every project'" />
51+ <ul class="m-0 flex list-none flex-col gap-1.5 p-0 font-mono text-xs">
52+ <li v-for="[dir, n] in directories" :key="dir" class="flex justify-between text-sk-muted">
53+ <span>{{ dir }}</span><span class="text-sk-faint">{{ n }}</span>
54+ </li>
55+ <li v-if="!directories.length" class="text-sk-faint">Nothing is loaded from Home.</li>
56+ </ul>
57+ </div>
58+
59+ <div class="min-w-0 p-[22px]">
60+ <ScopeInventoryList
61+ :items="items"
62+ :statuses="scope.statuses.value"
63+ :root="inventory.root"
64+ mode="home"
65+ :flagged="flagged"
66+ :only-ids="onlyIds"
67+ @select="scope.openItem"
68+ @move="(i: InventoryItem) => scope.askMove([i])"
69+ @import="importOne"
70+ >
71+ <template #top>
72+ <SkNote v-if="inventory.errors.length" tone="accent" class="mb-1">
73+ <button type="button" class="cursor-pointer border-0 bg-transparent p-0 text-left font-semibold text-inherit" @click="errorsOpen = !errorsOpen">
74+ {{ inventory.errors.length }} item{{ inventory.errors.length > 1 ? 's' : '' }} could not be read
75+ <span class="font-normal opacity-80">· {{ errorsOpen ? 'hide' : 'show' }} details</span>
76+ </button>
77+ <ScopeScanErrors v-if="errorsOpen" :errors="inventory.errors" :root="inventory.root" />
78+ </SkNote>
79+ </template>
80+ </ScopeInventoryList>
81+ </div>
82+ </div>
83+ </SkPanel>
84+
85+ <HomeSuggestions
86+ v-if="flagged.size"
87+ :items="items.filter(i => flagged.has(i.id))"
88+ :flagged="flagged"
89+ :statuses="scope.statuses.value"
90+ :review="reviewOnly"
91+ @toggle-review="reviewOnly = !reviewOnly"
92+ @move="(i: InventoryItem) => scope.askMove([i])"
93+ />
94+ </div>
95+</template>
added packages/ui/app/components/home/HomeSuggestions.vue +40 -0
new file mode 100644
@@ -0,0 +1,40 @@
1+<script setup lang="ts">
2+import type { InventoryItem, ItemStatus } from '@skilly/core'
3+import type { ProjectSpecific } from '../../utils/inventoryView'
4+
5+/**
6+ * "2 · N global items look project-specific" (mockup 6): gradient tiles that open the Move modal.
7+ * Computed client side from catalog usage (brief 4.5); nothing is ever moved automatically.
8+ */
9+defineProps<{
10+ items: InventoryItem[]
11+ flagged: Map<string, ProjectSpecific>
12+ statuses: Record<string, ItemStatus>
13+ /** The inventory above is filtered to these items. */
14+ review: boolean
15+}>()
16+defineEmits<{ toggleReview: [], move: [item: InventoryItem] }>()
17+
18+const catalogLabel = (st?: ItemStatus) => (st?.state === 'in-sync' ? 'in catalog' : 'local')
19+</script>
20+
21+<template>
22+ <SkPanel hero padded class="flex flex-col gap-4">
23+ <SkStepHeader step="2" :title="`${items.length} global item${items.length === 1 ? '' : 's'} look${items.length === 1 ? 's' : ''} project-specific`" hint="Move them to the catalog and enable them only where they are needed.">
24+ <template #actions>
25+ <SkButton variant="secondary" pill :label="review ? 'Show all' : 'Review'" @click="$emit('toggleReview')" />
26+ </template>
27+ </SkStepHeader>
28+ <div class="grid gap-3.5" style="grid-template-columns: repeat(auto-fill, minmax(230px, 1fr))">
29+ <SkSuggestionTile
30+ v-for="(i, n) in items"
31+ :key="i.id"
32+ :variant="suggestionVariant(n)"
33+ :name="i.name"
34+ :hint="`${AGENT_LABELS[i.agent]} · ${TYPE_TAG[i.type]} · ${catalogLabel(statuses[i.id])}`"
35+ :title="flagged.get(i.id)?.hint"
36+ @click="$emit('move', i)"
37+ />
38+ </div>
39+ </SkPanel>
40+</template>
new file mode 100644
@@ -0,0 +1,40 @@
1+<script setup lang="ts">
2+import type { InventoryItem, ItemStatus } from '@skilly/core'
3+import type { ProjectSpecific } from '../../utils/inventoryView'
4+
5+/**
6+ * "2 · N global items look project-specific" (mockup 6): gradient tiles that open the Move modal.
7+ * Computed client side from catalog usage (brief 4.5); nothing is ever moved automatically.
8+ */
9+defineProps<{
10+ items: InventoryItem[]
11+ flagged: Map<string, ProjectSpecific>
12+ statuses: Record<string, ItemStatus>
13+ /** The inventory above is filtered to these items. */
14+ review: boolean
15+}>()
16+defineEmits<{ toggleReview: [], move: [item: InventoryItem] }>()
17+
18+const catalogLabel = (st?: ItemStatus) => (st?.state === 'in-sync' ? 'in catalog' : 'local')
19+</script>
20+
21+<template>
22+ <SkPanel hero padded class="flex flex-col gap-4">
23+ <SkStepHeader step="2" :title="`${items.length} global item${items.length === 1 ? '' : 's'} look${items.length === 1 ? 's' : ''} project-specific`" hint="Move them to the catalog and enable them only where they are needed.">
24+ <template #actions>
25+ <SkButton variant="secondary" pill :label="review ? 'Show all' : 'Review'" @click="$emit('toggleReview')" />
26+ </template>
27+ </SkStepHeader>
28+ <div class="grid gap-3.5" style="grid-template-columns: repeat(auto-fill, minmax(230px, 1fr))">
29+ <SkSuggestionTile
30+ v-for="(i, n) in items"
31+ :key="i.id"
32+ :variant="suggestionVariant(n)"
33+ :name="i.name"
34+ :hint="`${AGENT_LABELS[i.agent]} · ${TYPE_TAG[i.type]} · ${catalogLabel(statuses[i.id])}`"
35+ :title="flagged.get(i.id)?.hint"
36+ @click="$emit('move', i)"
37+ />
38+ </div>
39+ </SkPanel>
40+</template>
added packages/ui/app/components/projects/ProjectsTable.vue +138 -0
new file mode 100644
@@ -0,0 +1,138 @@
1+<script setup lang="ts">
2+import type { ProjectSummary, ScopeState } from '@skilly/core'
3+import type { Tone } from '../../utils/status'
4+
5+/**
6+ * Projects table (mockup 8): a pinned Home row, then one row per project with agents, presets, item count and
7+ * sync status. Status needs a scan of each scope: it is fetched lazily, three at a time, and tolerates failures.
8+ */
9+const { projects, loaded, error: projectsError, refresh: refreshProjects } = useProjects()
10+const { request } = useApi()
11+const catalog = useCatalog()
12+const addOpen = useAddProjectModal()
13+
14+interface Summary {
15+ managed: boolean
16+ agents: string
17+ presets: string[]
18+ items: number
19+ status: { tone: Tone, label: string }
20+ root?: string
21+}
22+const summaries = useState<Record<string, Summary | null>>('skilly-scope-summaries', () => ({}))
23+
24+function summarize(s: ScopeState, fallbackAgents: string): Summary {
25+ const cfg = s.config
26+ const inv = s.inventory
27+ const nonInstr = inv.items.filter(i => i.type !== 'instructions')
28+ let items = nonInstr.length
29+ if (cfg) {
30+ const entries = resolveItems(catalog.presets.value, cfg.presets, cfg.add).entries
31+ items = entries.filter(e => !matchIn(cfg.exclude[e.type], e.name)).length
32+ }
33+ const targets = cfg?.targets ?? inv.agentsDetected
34+ const agents = targets.length ? targets.map(a => AGENT_LABELS[a]).join(', ') : fallbackAgents
35+ const status = !cfg ? { tone: 'faint' as Tone, label: 'Not managed' } : summarizePlan(s.plan)
36+ return { managed: !!cfg, agents, presets: cfg?.presets ?? [], items, status, root: inv.root }
37+}
38+
39+async function loadSummaries() {
40+ const ids = ['home', ...projects.value.filter(p => p.exists).map(p => p.id)]
41+ const queue = [...ids]
42+ const worker = async () => {
43+ for (let id = queue.shift(); id; id = queue.shift()) {
44+ try {
45+ const s = await request<ScopeState>(`/api/scopes/${encodeURIComponent(id)}/state`, { silent: true })
46+ summaries.value = { ...summaries.value, [id]: summarize(s, '—') }
47+ } catch {
48+ summaries.value = { ...summaries.value, [id]: null }
49+ }
50+ }
51+ }
52+ await Promise.all([worker(), worker(), worker()])
53+}
54+
55+onMounted(async () => {
56+ if (!catalog.data.value) catalog.refresh()
57+ // Projects may have changed on disk (e.g. a folder was deleted) since the layout loaded them.
58+ if (loaded.value) await refreshProjects()
59+ else await new Promise<void>((res) => { const stop = watch(loaded, (v) => { if (v) { stop(); res() } }) })
60+ await loadSummaries()
61+})
62+
63+const COLS = 'minmax(0,2.2fr) minmax(0,1.3fr) minmax(0,1.4fr) 64px minmax(0,1.5fr)'
64+
65+function go(id: string) {
66+ navigateTo(id === 'home' ? '/home' : `/projects/${encodeURIComponent(id)}`)
67+}
68+
69+const rowStatus = (p: ProjectSummary): { tone: Tone, label: string } | null => {
70+ if (!p.exists) return { tone: 'accent', label: 'Folder missing' }
71+ return summaries.value[p.id]?.status ?? null
72+}
73+const agentsOf = (p: ProjectSummary) => summaries.value[p.id]?.agents
74+ ?? (p.agentsDetected.length ? p.agentsDetected.map(a => AGENT_LABELS[a]).join(', ') : '—')
75+const countOf = (p: ProjectSummary) => summaries.value[p.id]?.items ?? Object.entries(p.counts).filter(([t]) => t !== 'instructions').reduce((n, [, c]) => n + c, 0)
76+</script>
77+
78+<template>
79+ <SkPanel>
80+ <SkRow head :cols="COLS">
81+ <span>Project</span><span>Agents</span><span>Presets</span><span>Items</span><span>Status</span>
82+ </SkRow>
83+
84+ <!-- Home, pinned -->
85+ <SkRow interactive :cols="COLS" class="py-3" tabindex="0" role="link" aria-label="Open Home" @click="go('home')" @keydown.enter="go('home')">
86+ <div class="flex min-w-0 items-center gap-2.5">
87+ <SkAvatar :size="28" />
88+ <div class="min-w-0">
89+ <div class="truncate font-medium">Home · user-level config</div>
90+ <div class="truncate font-mono text-[11px] text-sk-faint">{{ summaries.home?.root ?? '~' }}</div>
91+ </div>
92+ </div>
93+ <span class="truncate text-sk-muted">{{ summaries.home?.agents ?? '' }}</span>
94+ <span class="truncate text-sk-muted">{{ summaries.home ? (summaries.home.presets.join(', ') || '—') : '' }}</span>
95+ <span class="font-mono text-xs text-sk-muted">{{ summaries.home?.items ?? '' }}</span>
96+ <SkStatus v-if="summaries.home" :tone="summaries.home.status.tone" :label="summaries.home.status.label" />
97+ <SkStatus v-else-if="summaries.home === null" tone="faint" label="Unavailable" />
98+ <USkeleton v-else class="h-4 w-20" />
99+ </SkRow>
100+
101+ <template v-if="loaded">
102+ <SkRow
103+ v-for="p in projects"
104+ :key="p.id"
105+ interactive
106+ :cols="COLS"
107+ class="py-3"
108+ tabindex="0"
109+ role="link"
110+ :aria-label="`Open ${p.name}`"
111+ :faded="false"
112+ @click="go(p.id)"
113+ @keydown.enter="go(p.id)"
114+ >
115+ <div class="min-w-0">
116+ <div class="truncate font-medium">{{ p.name }}</div>
117+ <div class="truncate font-mono text-[11px] text-sk-faint" :title="p.path">{{ p.path }}</div>
118+ </div>
119+ <span class="truncate text-sk-muted">{{ agentsOf(p) }}</span>
120+ <span class="truncate text-sk-muted" :title="summaries[p.id]?.presets.join(', ')">{{ p.exists && summaries[p.id] ? (summaries[p.id]!.presets.join(', ') || '—') : '' }}</span>
121+ <span class="font-mono text-xs text-sk-muted">{{ countOf(p) }}</span>
122+ <template v-if="rowStatus(p)"><SkStatus :tone="rowStatus(p)!.tone" :label="rowStatus(p)!.label" /></template>
123+ <SkStatus v-else-if="summaries[p.id] === null" tone="faint" label="Unavailable" />
124+ <USkeleton v-else class="h-4 w-20" />
125+ </SkRow>
126+ </template>
127+ <div v-else class="flex flex-col gap-2 border-t border-sk-line2 p-4"><USkeleton class="h-9 w-full" /><USkeleton class="h-9 w-full" /></div>
128+
129+ <SkNote v-if="projectsError" tone="accent" class="m-3">
130+ <span class="font-medium">Could not load projects.</span> {{ projectsError }}
131+ <template #aside><SkButton size="sm" label="Retry" @click="refreshProjects()" /></template>
132+ </SkNote>
133+ <SkEmpty v-else-if="loaded && !projects.length" title="Add your first project">
134+ Pick a folder to see which skills, rules, MCP servers and instructions Claude Code and Codex use there, then manage them from your catalog.
135+ <template #actions><SkButton variant="primary" label="Add project" @click="addOpen = true" /></template>
136+ </SkEmpty>
137+ </SkPanel>
138+</template>
new file mode 100644
@@ -0,0 +1,138 @@
1+<script setup lang="ts">
2+import type { ProjectSummary, ScopeState } from '@skilly/core'
3+import type { Tone } from '../../utils/status'
4+
5+/**
6+ * Projects table (mockup 8): a pinned Home row, then one row per project with agents, presets, item count and
7+ * sync status. Status needs a scan of each scope: it is fetched lazily, three at a time, and tolerates failures.
8+ */
9+const { projects, loaded, error: projectsError, refresh: refreshProjects } = useProjects()
10+const { request } = useApi()
11+const catalog = useCatalog()
12+const addOpen = useAddProjectModal()
13+
14+interface Summary {
15+ managed: boolean
16+ agents: string
17+ presets: string[]
18+ items: number
19+ status: { tone: Tone, label: string }
20+ root?: string
21+}
22+const summaries = useState<Record<string, Summary | null>>('skilly-scope-summaries', () => ({}))
23+
24+function summarize(s: ScopeState, fallbackAgents: string): Summary {
25+ const cfg = s.config
26+ const inv = s.inventory
27+ const nonInstr = inv.items.filter(i => i.type !== 'instructions')
28+ let items = nonInstr.length
29+ if (cfg) {
30+ const entries = resolveItems(catalog.presets.value, cfg.presets, cfg.add).entries
31+ items = entries.filter(e => !matchIn(cfg.exclude[e.type], e.name)).length
32+ }
33+ const targets = cfg?.targets ?? inv.agentsDetected
34+ const agents = targets.length ? targets.map(a => AGENT_LABELS[a]).join(', ') : fallbackAgents
35+ const status = !cfg ? { tone: 'faint' as Tone, label: 'Not managed' } : summarizePlan(s.plan)
36+ return { managed: !!cfg, agents, presets: cfg?.presets ?? [], items, status, root: inv.root }
37+}
38+
39+async function loadSummaries() {
40+ const ids = ['home', ...projects.value.filter(p => p.exists).map(p => p.id)]
41+ const queue = [...ids]
42+ const worker = async () => {
43+ for (let id = queue.shift(); id; id = queue.shift()) {
44+ try {
45+ const s = await request<ScopeState>(`/api/scopes/${encodeURIComponent(id)}/state`, { silent: true })
46+ summaries.value = { ...summaries.value, [id]: summarize(s, '—') }
47+ } catch {
48+ summaries.value = { ...summaries.value, [id]: null }
49+ }
50+ }
51+ }
52+ await Promise.all([worker(), worker(), worker()])
53+}
54+
55+onMounted(async () => {
56+ if (!catalog.data.value) catalog.refresh()
57+ // Projects may have changed on disk (e.g. a folder was deleted) since the layout loaded them.
58+ if (loaded.value) await refreshProjects()
59+ else await new Promise<void>((res) => { const stop = watch(loaded, (v) => { if (v) { stop(); res() } }) })
60+ await loadSummaries()
61+})
62+
63+const COLS = 'minmax(0,2.2fr) minmax(0,1.3fr) minmax(0,1.4fr) 64px minmax(0,1.5fr)'
64+
65+function go(id: string) {
66+ navigateTo(id === 'home' ? '/home' : `/projects/${encodeURIComponent(id)}`)
67+}
68+
69+const rowStatus = (p: ProjectSummary): { tone: Tone, label: string } | null => {
70+ if (!p.exists) return { tone: 'accent', label: 'Folder missing' }
71+ return summaries.value[p.id]?.status ?? null
72+}
73+const agentsOf = (p: ProjectSummary) => summaries.value[p.id]?.agents
74+ ?? (p.agentsDetected.length ? p.agentsDetected.map(a => AGENT_LABELS[a]).join(', ') : '—')
75+const countOf = (p: ProjectSummary) => summaries.value[p.id]?.items ?? Object.entries(p.counts).filter(([t]) => t !== 'instructions').reduce((n, [, c]) => n + c, 0)
76+</script>
77+
78+<template>
79+ <SkPanel>
80+ <SkRow head :cols="COLS">
81+ <span>Project</span><span>Agents</span><span>Presets</span><span>Items</span><span>Status</span>
82+ </SkRow>
83+
84+ <!-- Home, pinned -->
85+ <SkRow interactive :cols="COLS" class="py-3" tabindex="0" role="link" aria-label="Open Home" @click="go('home')" @keydown.enter="go('home')">
86+ <div class="flex min-w-0 items-center gap-2.5">
87+ <SkAvatar :size="28" />
88+ <div class="min-w-0">
89+ <div class="truncate font-medium">Home · user-level config</div>
90+ <div class="truncate font-mono text-[11px] text-sk-faint">{{ summaries.home?.root ?? '~' }}</div>
91+ </div>
92+ </div>
93+ <span class="truncate text-sk-muted">{{ summaries.home?.agents ?? '' }}</span>
94+ <span class="truncate text-sk-muted">{{ summaries.home ? (summaries.home.presets.join(', ') || '—') : '' }}</span>
95+ <span class="font-mono text-xs text-sk-muted">{{ summaries.home?.items ?? '' }}</span>
96+ <SkStatus v-if="summaries.home" :tone="summaries.home.status.tone" :label="summaries.home.status.label" />
97+ <SkStatus v-else-if="summaries.home === null" tone="faint" label="Unavailable" />
98+ <USkeleton v-else class="h-4 w-20" />
99+ </SkRow>
100+
101+ <template v-if="loaded">
102+ <SkRow
103+ v-for="p in projects"
104+ :key="p.id"
105+ interactive
106+ :cols="COLS"
107+ class="py-3"
108+ tabindex="0"
109+ role="link"
110+ :aria-label="`Open ${p.name}`"
111+ :faded="false"
112+ @click="go(p.id)"
113+ @keydown.enter="go(p.id)"
114+ >
115+ <div class="min-w-0">
116+ <div class="truncate font-medium">{{ p.name }}</div>
117+ <div class="truncate font-mono text-[11px] text-sk-faint" :title="p.path">{{ p.path }}</div>
118+ </div>
119+ <span class="truncate text-sk-muted">{{ agentsOf(p) }}</span>
120+ <span class="truncate text-sk-muted" :title="summaries[p.id]?.presets.join(', ')">{{ p.exists && summaries[p.id] ? (summaries[p.id]!.presets.join(', ') || '—') : '' }}</span>
121+ <span class="font-mono text-xs text-sk-muted">{{ countOf(p) }}</span>
122+ <template v-if="rowStatus(p)"><SkStatus :tone="rowStatus(p)!.tone" :label="rowStatus(p)!.label" /></template>
123+ <SkStatus v-else-if="summaries[p.id] === null" tone="faint" label="Unavailable" />
124+ <USkeleton v-else class="h-4 w-20" />
125+ </SkRow>
126+ </template>
127+ <div v-else class="flex flex-col gap-2 border-t border-sk-line2 p-4"><USkeleton class="h-9 w-full" /><USkeleton class="h-9 w-full" /></div>
128+
129+ <SkNote v-if="projectsError" tone="accent" class="m-3">
130+ <span class="font-medium">Could not load projects.</span> {{ projectsError }}
131+ <template #aside><SkButton size="sm" label="Retry" @click="refreshProjects()" /></template>
132+ </SkNote>
133+ <SkEmpty v-else-if="loaded && !projects.length" title="Add your first project">
134+ Pick a folder to see which skills, rules, MCP servers and instructions Claude Code and Codex use there, then manage them from your catalog.
135+ <template #actions><SkButton variant="primary" label="Add project" @click="addOpen = true" /></template>
136+ </SkEmpty>
137+ </SkPanel>
138+</template>
added packages/ui/app/components/scope/ScopeAddPresetsModal.vue +72 -0
new file mode 100644
@@ -0,0 +1,72 @@
1+<script setup lang="ts">
2+import type { Preset } from '@skilly/core'
3+
4+/** "Add presets to {scope}" modal (mockup 11): search, checkbox list of presets, "Create a new preset" link. */
5+const props = defineProps<{ presets: Preset[], enabled: string[], scopeName: string, catalogExists: boolean }>()
6+const open = defineModel<boolean>('open', { default: false })
7+const emit = defineEmits<{ add: [names: string[]] }>()
8+
9+const search = ref('')
10+const chosen = ref<string[]>([])
11+
12+watch(open, (v) => {
13+ if (v) {
14+ search.value = ''
15+ chosen.value = []
16+ }
17+})
18+
19+const sizeOf = (p: Preset) => CAT_TYPES.reduce((n, t) => n + (p.items[t]?.length ?? 0), 0)
20+const itemNames = (p: Preset) => [...CAT_TYPES.flatMap(t => p.items[t] ?? []), ...p.includes.map(i => `+ ${i}`)]
21+
22+const list = computed(() => {
23+ const q = search.value.trim().toLowerCase()
24+ return props.presets
25+ .filter(p => !props.enabled.includes(p.name))
26+ .filter(p => !q || `${p.name} ${p.description ?? ''} ${itemNames(p).join(' ')}`.toLowerCase().includes(q))
27+})
28+
29+function toggle(name: string, on: boolean | 'indeterminate') {
30+ chosen.value = on === true ? [...new Set([...chosen.value, name])] : chosen.value.filter(n => n !== name)
31+}
32+</script>
33+
34+<template>
35+ <SkModal v-model:open="open" :title="`Add presets to ${scopeName}`" flush>
36+ <div class="flex flex-col">
37+ <div class="px-5 pb-4">
38+ <UInput v-model="search" placeholder="Search presets or items" class="w-full" />
39+ </div>
40+ <div class="max-h-[360px] overflow-y-auto border-t border-sk-line2">
41+ <label
42+ v-for="p in list"
43+ :key="p.name"
44+ class="flex cursor-pointer items-start gap-3 border-t border-sk-line2 px-5 py-3 first:border-t-0 hover:bg-sk-hover"
45+ >
46+ <UCheckbox class="mt-0.5" :model-value="chosen.includes(p.name)" :aria-label="p.name" @update:model-value="(v) => toggle(p.name, v)" />
47+ <span class="flex min-w-0 flex-col gap-0.5">
48+ <span class="flex items-baseline gap-2">
49+ <span class="font-medium">{{ p.name }}</span>
50+ <span class="font-mono text-[11px] text-sk-faint">{{ sizeOf(p) }} item{{ sizeOf(p) === 1 ? '' : 's' }}</span>
51+ </span>
52+ <span v-if="p.description" class="text-xs text-sk-muted">{{ p.description }}</span>
53+ <span class="truncate text-xs text-sk-muted" :title="itemNames(p).join(', ')">{{ itemNames(p).join(', ') }}</span>
54+ </span>
55+ </label>
56+ <SkEmpty v-if="!list.length">
57+ <template v-if="search">No preset matches your search.</template>
58+ <template v-else-if="!catalogExists">No catalog yet. Create it from the Catalog page first.</template>
59+ <template v-else-if="!presets.length">No preset in the catalog yet.</template>
60+ <template v-else>Every preset of the catalog is already enabled.</template>
61+ </SkEmpty>
62+ </div>
63+ </div>
64+ <template #footer>
65+ <NuxtLink :to="catalogExists ? '/catalog/presets/new' : '/catalog'" class="mr-auto text-sk-muted hover:text-sk-text" @click="open = false">
66+ {{ catalogExists ? 'Create a new preset' : 'Go to the catalog' }}
67+ </NuxtLink>
68+ <SkButton label="Cancel" @click="open = false" />
69+ <SkButton variant="primary" :disabled="!chosen.length" :label="chosen.length ? `Add ${chosen.length} preset${chosen.length > 1 ? 's' : ''}` : 'Add'" @click="emit('add', chosen)" />
70+ </template>
71+ </SkModal>
72+</template>
new file mode 100644
@@ -0,0 +1,72 @@
1+<script setup lang="ts">
2+import type { Preset } from '@skilly/core'
3+
4+/** "Add presets to {scope}" modal (mockup 11): search, checkbox list of presets, "Create a new preset" link. */
5+const props = defineProps<{ presets: Preset[], enabled: string[], scopeName: string, catalogExists: boolean }>()
6+const open = defineModel<boolean>('open', { default: false })
7+const emit = defineEmits<{ add: [names: string[]] }>()
8+
9+const search = ref('')
10+const chosen = ref<string[]>([])
11+
12+watch(open, (v) => {
13+ if (v) {
14+ search.value = ''
15+ chosen.value = []
16+ }
17+})
18+
19+const sizeOf = (p: Preset) => CAT_TYPES.reduce((n, t) => n + (p.items[t]?.length ?? 0), 0)
20+const itemNames = (p: Preset) => [...CAT_TYPES.flatMap(t => p.items[t] ?? []), ...p.includes.map(i => `+ ${i}`)]
21+
22+const list = computed(() => {
23+ const q = search.value.trim().toLowerCase()
24+ return props.presets
25+ .filter(p => !props.enabled.includes(p.name))
26+ .filter(p => !q || `${p.name} ${p.description ?? ''} ${itemNames(p).join(' ')}`.toLowerCase().includes(q))
27+})
28+
29+function toggle(name: string, on: boolean | 'indeterminate') {
30+ chosen.value = on === true ? [...new Set([...chosen.value, name])] : chosen.value.filter(n => n !== name)
31+}
32+</script>
33+
34+<template>
35+ <SkModal v-model:open="open" :title="`Add presets to ${scopeName}`" flush>
36+ <div class="flex flex-col">
37+ <div class="px-5 pb-4">
38+ <UInput v-model="search" placeholder="Search presets or items" class="w-full" />
39+ </div>
40+ <div class="max-h-[360px] overflow-y-auto border-t border-sk-line2">
41+ <label
42+ v-for="p in list"
43+ :key="p.name"
44+ class="flex cursor-pointer items-start gap-3 border-t border-sk-line2 px-5 py-3 first:border-t-0 hover:bg-sk-hover"
45+ >
46+ <UCheckbox class="mt-0.5" :model-value="chosen.includes(p.name)" :aria-label="p.name" @update:model-value="(v) => toggle(p.name, v)" />
47+ <span class="flex min-w-0 flex-col gap-0.5">
48+ <span class="flex items-baseline gap-2">
49+ <span class="font-medium">{{ p.name }}</span>
50+ <span class="font-mono text-[11px] text-sk-faint">{{ sizeOf(p) }} item{{ sizeOf(p) === 1 ? '' : 's' }}</span>
51+ </span>
52+ <span v-if="p.description" class="text-xs text-sk-muted">{{ p.description }}</span>
53+ <span class="truncate text-xs text-sk-muted" :title="itemNames(p).join(', ')">{{ itemNames(p).join(', ') }}</span>
54+ </span>
55+ </label>
56+ <SkEmpty v-if="!list.length">
57+ <template v-if="search">No preset matches your search.</template>
58+ <template v-else-if="!catalogExists">No catalog yet. Create it from the Catalog page first.</template>
59+ <template v-else-if="!presets.length">No preset in the catalog yet.</template>
60+ <template v-else>Every preset of the catalog is already enabled.</template>
61+ </SkEmpty>
62+ </div>
63+ </div>
64+ <template #footer>
65+ <NuxtLink :to="catalogExists ? '/catalog/presets/new' : '/catalog'" class="mr-auto text-sk-muted hover:text-sk-text" @click="open = false">
66+ {{ catalogExists ? 'Create a new preset' : 'Go to the catalog' }}
67+ </NuxtLink>
68+ <SkButton label="Cancel" @click="open = false" />
69+ <SkButton variant="primary" :disabled="!chosen.length" :label="chosen.length ? `Add ${chosen.length} preset${chosen.length > 1 ? 's' : ''}` : 'Add'" @click="emit('add', chosen)" />
70+ </template>
71+ </SkModal>
72+</template>
added packages/ui/app/components/scope/ScopeConfigTab.vue +20 -0
new file mode 100644
@@ -0,0 +1,20 @@
1+<script setup lang="ts">
2+/** Configuration tab: presets, items, inherited-from-Home (projects). */
3+const scope = useScopeContext()
4+
5+const missing = computed(() => scope.plan.value?.missing ?? [])
6+const noTarget = computed(() => scope.config.value && !scope.config.value.targets.length)
7+</script>
8+
9+<template>
10+ <div class="flex flex-col gap-[22px]">
11+ <SkNote v-if="noTarget" tone="accent">No target agent is selected: nothing will be written. Choose at least one agent under "Writes for" above.</SkNote>
12+ <SkNote v-if="missing.length" tone="accent">
13+ {{ missing.length }} referenced item{{ missing.length > 1 ? 's are' : ' is' }} not in the catalog and will be skipped:
14+ <span class="font-mono text-xs">{{ missing.map(m => `${m.type}/${m.name}`).join(', ') }}</span>
15+ </SkNote>
16+ <ScopePresetsSection />
17+ <ScopeItemsSection />
18+ <ScopeInheritedSection v-if="scope.kind === 'project'" />
19+ </div>
20+</template>
new file mode 100644
@@ -0,0 +1,20 @@
1+<script setup lang="ts">
2+/** Configuration tab: presets, items, inherited-from-Home (projects). */
3+const scope = useScopeContext()
4+
5+const missing = computed(() => scope.plan.value?.missing ?? [])
6+const noTarget = computed(() => scope.config.value && !scope.config.value.targets.length)
7+</script>
8+
9+<template>
10+ <div class="flex flex-col gap-[22px]">
11+ <SkNote v-if="noTarget" tone="accent">No target agent is selected: nothing will be written. Choose at least one agent under "Writes for" above.</SkNote>
12+ <SkNote v-if="missing.length" tone="accent">
13+ {{ missing.length }} referenced item{{ missing.length > 1 ? 's are' : ' is' }} not in the catalog and will be skipped:
14+ <span class="font-mono text-xs">{{ missing.map(m => `${m.type}/${m.name}`).join(', ') }}</span>
15+ </SkNote>
16+ <ScopePresetsSection />
17+ <ScopeItemsSection />
18+ <ScopeInheritedSection v-if="scope.kind === 'project'" />
19+ </div>
20+</template>
added packages/ui/app/components/scope/ScopeConflictChoice.vue +29 -0
new file mode 100644
@@ -0,0 +1,29 @@
1+<script setup lang="ts">
2+import type { ConflictResolution, PlanAction } from '@skilly/core'
3+
4+/**
5+ * The three ways to resolve a conflict, as a segmented choice. Picking one only stages it: the Review & apply
6+ * bar counts it and Apply executes it. keep-local is the default; take-catalog is impossible when the item is gone from the catalog.
7+ */
8+const props = defineProps<{ action: PlanAction, home?: boolean }>()
9+const model = defineModel<ConflictResolution>({ required: true })
10+
11+const options = computed(() => [
12+ { value: 'keep-local' as const, label: props.home ? 'Keep Home version' : 'Keep project version' },
13+ { value: 'take-catalog' as const, label: 'Take catalog version', disabled: props.action.conflict === 'missing-in-catalog', title: props.action.conflict === 'missing-in-catalog' ? 'The item is no longer in the catalog' : undefined },
14+ { value: 'push-to-catalog' as const, label: 'Push to catalog' }
15+])
16+
17+const EFFECT: Record<ConflictResolution, string> = {
18+ 'keep-local': 'The files stay as they are. skilly stops overwriting this item until you resolve it.',
19+ 'take-catalog': 'The catalog version replaces the local one on Apply.',
20+ 'push-to-catalog': 'The local version is copied into the catalog on Apply; other scopes that use it will get an update.'
21+}
22+</script>
23+
24+<template>
25+ <div class="flex flex-col gap-1.5">
26+ <SkSeg v-model="model" :options="options" aria-label="Resolution" />
27+ <p class="m-0 text-xs text-sk-muted">{{ EFFECT[model] }}</p>
28+ </div>
29+</template>
new file mode 100644
@@ -0,0 +1,29 @@
1+<script setup lang="ts">
2+import type { ConflictResolution, PlanAction } from '@skilly/core'
3+
4+/**
5+ * The three ways to resolve a conflict, as a segmented choice. Picking one only stages it: the Review & apply
6+ * bar counts it and Apply executes it. keep-local is the default; take-catalog is impossible when the item is gone from the catalog.
7+ */
8+const props = defineProps<{ action: PlanAction, home?: boolean }>()
9+const model = defineModel<ConflictResolution>({ required: true })
10+
11+const options = computed(() => [
12+ { value: 'keep-local' as const, label: props.home ? 'Keep Home version' : 'Keep project version' },
13+ { value: 'take-catalog' as const, label: 'Take catalog version', disabled: props.action.conflict === 'missing-in-catalog', title: props.action.conflict === 'missing-in-catalog' ? 'The item is no longer in the catalog' : undefined },
14+ { value: 'push-to-catalog' as const, label: 'Push to catalog' }
15+])
16+
17+const EFFECT: Record<ConflictResolution, string> = {
18+ 'keep-local': 'The files stay as they are. skilly stops overwriting this item until you resolve it.',
19+ 'take-catalog': 'The catalog version replaces the local one on Apply.',
20+ 'push-to-catalog': 'The local version is copied into the catalog on Apply; other scopes that use it will get an update.'
21+}
22+</script>
23+
24+<template>
25+ <div class="flex flex-col gap-1.5">
26+ <SkSeg v-model="model" :options="options" aria-label="Resolution" />
27+ <p class="m-0 text-xs text-sk-muted">{{ EFFECT[model] }}</p>
28+ </div>
29+</template>
added packages/ui/app/components/scope/ScopeConflictCompare.vue +84 -0
new file mode 100644
@@ -0,0 +1,84 @@
1+<script setup lang="ts">
2+import type { FileContent, PlanAction } from '@skilly/core'
3+
4+/**
5+ * Diff of a conflicting item against its catalog version (design/BRIEF.md 4.2). Only standalone files have one
6+ * (SKILL.md, rule and subagent markdown). Entries of shared configuration files (.mcp.json, settings.json, the
7+ * AGENTS.md block) get the note "no standalone diff".
8+ */
9+const props = withDefaults(defineProps<{
10+ action: PlanAction
11+ root: string
12+ mode?: 'unified' | 'split'
13+ /** Show the "differs from the catalog: 3 lines added, 1 removed" sentence above the diff. */
14+ sentence?: boolean
15+ localLabel?: string
16+}>(), { mode: 'unified', sentence: true, localLabel: 'This project' })
17+
18+const { request } = useApi()
19+
20+const loading = ref(true)
21+const error = ref<string | null>(null)
22+const note = ref<string | null>(null)
23+const local = ref<string | undefined>()
24+const catalogText = ref<string | undefined>()
25+
26+const stats = computed(() => (local.value !== undefined && catalogText.value !== undefined
27+ ? diffStats(diffLines(catalogText.value, local.value))
28+ : null))
29+
30+async function load() {
31+ loading.value = true
32+ error.value = null
33+ note.value = null
34+ local.value = undefined
35+ catalogText.value = undefined
36+ const file = comparableFile(props.action)
37+ if (!file) {
38+ note.value = 'Lives in a shared configuration file: no standalone diff.'
39+ loading.value = false
40+ return
41+ }
42+ const errors: string[] = []
43+ try {
44+ local.value = (await request<FileContent>('/api/files', { query: { path: joinPath(props.root, file) }, silent: true })).content
45+ } catch (e) {
46+ errors.push(`File: ${(e as Error).message}`)
47+ }
48+ if (props.action.conflict === 'missing-in-catalog') note.value = 'The item no longer exists in the catalog.'
49+ else {
50+ try {
51+ catalogText.value = (await request<{ content: string }>(`/api/catalog/items/${props.action.type}/${encodeURIComponent(props.action.name)}`, { silent: true })).content
52+ } catch (e) {
53+ errors.push(`Catalog: ${(e as Error).message}`)
54+ }
55+ }
56+ error.value = errors.join(' · ') || null
57+ loading.value = false
58+}
59+
60+watch(() => props.action.id, load, { immediate: true })
61+</script>
62+
63+<template>
64+ <div class="flex flex-col gap-2">
65+ <USkeleton v-if="loading" class="h-20 w-full rounded-[10px]" />
66+ <template v-else>
67+ <p v-if="sentence && stats" class="m-0 text-pretty">
68+ This {{ localLabel === 'Home' ? 'Home' : 'project' }}'s <code class="font-mono text-xs font-medium">{{ action.name }}</code>
69+ {{ stats.added || stats.removed ? `differs from the catalog: ${describeDiff(stats)}.` : 'has the same text as the catalog (only the rendering or metadata differs).' }}
70+ </p>
71+ <SkNote v-if="note">{{ note }}</SkNote>
72+ <SkNote v-if="error" tone="accent">{{ error }}</SkNote>
73+ <SkDiff
74+ v-if="local !== undefined && catalogText !== undefined"
75+ :old-text="catalogText"
76+ :new-text="local"
77+ old-label="Catalog"
78+ :new-label="localLabel"
79+ :mode="mode"
80+ :context="3"
81+ />
82+ </template>
83+ </div>
84+</template>
new file mode 100644
@@ -0,0 +1,84 @@
1+<script setup lang="ts">
2+import type { FileContent, PlanAction } from '@skilly/core'
3+
4+/**
5+ * Diff of a conflicting item against its catalog version (design/BRIEF.md 4.2). Only standalone files have one
6+ * (SKILL.md, rule and subagent markdown). Entries of shared configuration files (.mcp.json, settings.json, the
7+ * AGENTS.md block) get the note "no standalone diff".
8+ */
9+const props = withDefaults(defineProps<{
10+ action: PlanAction
11+ root: string
12+ mode?: 'unified' | 'split'
13+ /** Show the "differs from the catalog: 3 lines added, 1 removed" sentence above the diff. */
14+ sentence?: boolean
15+ localLabel?: string
16+}>(), { mode: 'unified', sentence: true, localLabel: 'This project' })
17+
18+const { request } = useApi()
19+
20+const loading = ref(true)
21+const error = ref<string | null>(null)
22+const note = ref<string | null>(null)
23+const local = ref<string | undefined>()
24+const catalogText = ref<string | undefined>()
25+
26+const stats = computed(() => (local.value !== undefined && catalogText.value !== undefined
27+ ? diffStats(diffLines(catalogText.value, local.value))
28+ : null))
29+
30+async function load() {
31+ loading.value = true
32+ error.value = null
33+ note.value = null
34+ local.value = undefined
35+ catalogText.value = undefined
36+ const file = comparableFile(props.action)
37+ if (!file) {
38+ note.value = 'Lives in a shared configuration file: no standalone diff.'
39+ loading.value = false
40+ return
41+ }
42+ const errors: string[] = []
43+ try {
44+ local.value = (await request<FileContent>('/api/files', { query: { path: joinPath(props.root, file) }, silent: true })).content
45+ } catch (e) {
46+ errors.push(`File: ${(e as Error).message}`)
47+ }
48+ if (props.action.conflict === 'missing-in-catalog') note.value = 'The item no longer exists in the catalog.'
49+ else {
50+ try {
51+ catalogText.value = (await request<{ content: string }>(`/api/catalog/items/${props.action.type}/${encodeURIComponent(props.action.name)}`, { silent: true })).content
52+ } catch (e) {
53+ errors.push(`Catalog: ${(e as Error).message}`)
54+ }
55+ }
56+ error.value = errors.join(' · ') || null
57+ loading.value = false
58+}
59+
60+watch(() => props.action.id, load, { immediate: true })
61+</script>
62+
63+<template>
64+ <div class="flex flex-col gap-2">
65+ <USkeleton v-if="loading" class="h-20 w-full rounded-[10px]" />
66+ <template v-else>
67+ <p v-if="sentence && stats" class="m-0 text-pretty">
68+ This {{ localLabel === 'Home' ? 'Home' : 'project' }}'s <code class="font-mono text-xs font-medium">{{ action.name }}</code>
69+ {{ stats.added || stats.removed ? `differs from the catalog: ${describeDiff(stats)}.` : 'has the same text as the catalog (only the rendering or metadata differs).' }}
70+ </p>
71+ <SkNote v-if="note">{{ note }}</SkNote>
72+ <SkNote v-if="error" tone="accent">{{ error }}</SkNote>
73+ <SkDiff
74+ v-if="local !== undefined && catalogText !== undefined"
75+ :old-text="catalogText"
76+ :new-text="local"
77+ old-label="Catalog"
78+ :new-label="localLabel"
79+ :mode="mode"
80+ :context="3"
81+ />
82+ </template>
83+ </div>
84+</template>
added packages/ui/app/components/scope/ScopeFilesTab.vue +21 -0
new file mode 100644
@@ -0,0 +1,21 @@
1+<script setup lang="ts">
2+import type { InventoryItem } from '@skilly/core'
3+
4+/** Files tab: the raw inventory of the project by type, with the item drawer (mockup tray style). */
5+const scope = useScopeContext()
6+</script>
7+
8+<template>
9+ <SkSection title="Files on disk" hint="Everything the agents can see in this project, as scanned. Click a row for details.">
10+ <SkPanel padded>
11+ <ScopeInventoryList
12+ :items="scope.inventory.value?.items ?? []"
13+ :statuses="scope.statuses.value"
14+ :root="scope.rootPath.value"
15+ mode="project"
16+ @select="scope.openItem"
17+ @import="(i: InventoryItem) => scope.askImport([i])"
18+ />
19+ </SkPanel>
20+ </SkSection>
21+</template>
new file mode 100644
@@ -0,0 +1,21 @@
1+<script setup lang="ts">
2+import type { InventoryItem } from '@skilly/core'
3+
4+/** Files tab: the raw inventory of the project by type, with the item drawer (mockup tray style). */
5+const scope = useScopeContext()
6+</script>
7+
8+<template>
9+ <SkSection title="Files on disk" hint="Everything the agents can see in this project, as scanned. Click a row for details.">
10+ <SkPanel padded>
11+ <ScopeInventoryList
12+ :items="scope.inventory.value?.items ?? []"
13+ :statuses="scope.statuses.value"
14+ :root="scope.rootPath.value"
15+ mode="project"
16+ @select="scope.openItem"
17+ @import="(i: InventoryItem) => scope.askImport([i])"
18+ />
19+ </SkPanel>
20+ </SkSection>
21+</template>
added packages/ui/app/components/scope/ScopeInheritedSection.vue +116 -0
new file mode 100644
@@ -0,0 +1,116 @@
1+<script setup lang="ts">
2+import type { AgentId, ItemType } from '@skilly/core'
3+
4+/**
5+ * "Inherited from Home" (projects only, mockup 9): Home items this project also sees, with hide switches.
6+ * Only skills and MCP servers can be hidden, and only for Claude Code; Codex cannot hide per project (brief 4.6).
7+ */
8+const scope = useScopeContext()
9+const { home: homeInv, loading, refresh } = useHomeInventory()
10+
11+onMounted(() => {
12+ if (!homeInv.value) refresh()
13+})
14+
15+interface Inherited { key: string, type: ItemType, name: string, agents: AgentId[], plugin: boolean, hideable: boolean }
16+
17+const all = computed<Inherited[]>(() => {
18+ const map = new Map<string, Inherited>()
19+ for (const i of homeInv.value?.items ?? []) {
20+ if (i.type === 'instructions') continue
21+ const name = itemDisplayName(i)
22+ const key = `${i.type}/${name}`
23+ const e = map.get(key) ?? { key, type: i.type, name, agents: [], plugin: !!i.source.plugin, hideable: i.type === 'skill' || i.type === 'mcp' }
24+ if (!e.agents.includes(i.agent)) e.agents.push(i.agent)
25+ e.plugin = e.plugin && !!i.source.plugin
26+ map.set(key, e)
27+ }
28+ const order: ItemType[] = ['skill', 'mcp', 'rule', 'hook', 'agent']
29+ return [...map.values()].sort((a, b) => order.indexOf(a.type) - order.indexOf(b.type) || a.name.localeCompare(b.name))
30+})
31+
32+const search = ref('')
33+const showFixed = ref(false)
34+const LIMIT = 40
35+const showAllHideable = ref(false)
36+
37+const filtered = computed(() => {
38+ const q = search.value.trim().toLowerCase()
39+ return all.value.filter(i => !q || i.name.toLowerCase().includes(q))
40+})
41+const hideable = computed(() => filtered.value.filter(i => i.hideable))
42+const fixed = computed(() => filtered.value.filter(i => !i.hideable))
43+const shownHideable = computed(() => (showAllHideable.value ? hideable.value : hideable.value.slice(0, LIMIT)))
44+
45+const listFor = (t: ItemType) => (t === 'skill' ? 'skills' : 'mcp') as 'skills' | 'mcp'
46+const hiddenMatch = (i: Inherited) => (i.hideable ? matchIn(scope.config.value?.hideGlobal[listFor(i.type)], i.name) : null)
47+
48+function note(i: Inherited): string {
49+ const noun = i.type === 'mcp' ? 'MCP servers' : 'skills'
50+ if (!i.hideable) return 'Can\'t be hidden'
51+ if (i.plugin) return 'Provided by a plugin'
52+ const hidden = !!hiddenMatch(i)
53+ const claude = i.agents.includes('claude')
54+ const codex = i.agents.includes('codex')
55+ if (!claude) return `Codex can't hide ${noun}`
56+ if (hidden) return codex ? 'Hidden for Claude Code · Codex can\'t hide it' : 'Hidden here'
57+ return codex ? 'Visible · Codex can\'t hide it' : 'Visible'
58+}
59+
60+const canSwitch = (i: Inherited) => i.hideable && !i.plugin && i.agents.includes('claude') && (hiddenMatch(i)?.exact ?? true)
61+
62+function setHidden(i: Inherited, on: boolean) {
63+ scope.edit((c) => {
64+ const k = listFor(i.type)
65+ c.hideGlobal[k] = toggleIn(c.hideGlobal[k], i.name, on)
66+ })
67+}
68+</script>
69+
70+<template>
71+ <SkSection title="Inherited from Home">
72+ <template #actions>
73+ <span class="text-pretty text-sk-muted">Hiding is a fallback. Agents support it only partially.</span>
74+ <NuxtLink to="/home" class="text-sk-accent-text hover:underline">Move it out of Home instead</NuxtLink>
75+ </template>
76+
77+ <SkPanel>
78+ <div v-if="all.length > 8" class="border-b border-sk-line2 px-4 py-2.5">
79+ <UInput v-model="search" placeholder="Search Home items" size="sm" class="w-full max-w-xs" />
80+ </div>
81+
82+ <SkRow v-for="i in shownHideable" :key="i.key" cols="minmax(0,1fr) auto minmax(0,220px) 28px" class="text-[13px]">
83+ <span class="flex min-w-0 items-baseline gap-2"><span class="truncate font-medium">{{ i.name }}</span><SkTypeTag :type="i.type" /></span>
84+ <span class="text-sk-muted">{{ i.agents.map(a => AGENT_LABELS[a]).join(', ') }}</span>
85+ <span class="text-right text-xs text-sk-muted">{{ note(i) }}</span>
86+ <span class="inline-flex justify-end" :class="!canSwitch(i) && 'opacity-40'">
87+ <USwitch
88+ :model-value="!!hiddenMatch(i)"
89+ :disabled="!canSwitch(i)"
90+ :aria-label="`Hide ${i.name} in this project`"
91+ @update:model-value="(v: boolean) => setHidden(i, v)"
92+ />
93+ </span>
94+ </SkRow>
95+
96+ <div v-if="hideable.length > shownHideable.length" class="border-t border-sk-line2 px-4 py-2.5 text-center">
97+ <SkLinkAction @click="showAllHideable = true">Show all {{ hideable.length }} skills and MCP servers</SkLinkAction>
98+ </div>
99+
100+ <template v-if="showFixed || search">
101+ <SkRow v-for="i in fixed" :key="i.key" cols="minmax(0,1fr) auto minmax(0,220px) 28px" class="text-[13px]">
102+ <span class="flex min-w-0 items-baseline gap-2"><span class="truncate font-medium">{{ i.name }}</span><SkTypeTag :type="i.type" /></span>
103+ <span class="text-sk-muted">{{ i.agents.map(a => AGENT_LABELS[a]).join(', ') }}</span>
104+ <span class="text-right text-xs text-sk-muted">{{ note(i) }}</span>
105+ <span class="inline-flex justify-end opacity-40"><USwitch :model-value="false" disabled :aria-label="`${i.name} can't be hidden`" /></span>
106+ </SkRow>
107+ </template>
108+ <div v-if="fixed.length && !showFixed && !search" class="border-t border-sk-line2 px-4 py-2.5 text-center">
109+ <SkLinkAction @click="showFixed = true">Show {{ fixed.length }} rules, hooks and subagents that can't be hidden</SkLinkAction>
110+ </div>
111+
112+ <SkEmpty v-if="!filtered.length && !loading">{{ search ? 'No Home item matches your search.' : 'Nothing is installed in Home: there is nothing to inherit.' }}</SkEmpty>
113+ <div v-if="loading && !homeInv" class="p-4"><USkeleton class="h-8 w-full" /></div>
114+ </SkPanel>
115+ </SkSection>
116+</template>
new file mode 100644
@@ -0,0 +1,116 @@
1+<script setup lang="ts">
2+import type { AgentId, ItemType } from '@skilly/core'
3+
4+/**
5+ * "Inherited from Home" (projects only, mockup 9): Home items this project also sees, with hide switches.
6+ * Only skills and MCP servers can be hidden, and only for Claude Code; Codex cannot hide per project (brief 4.6).
7+ */
8+const scope = useScopeContext()
9+const { home: homeInv, loading, refresh } = useHomeInventory()
10+
11+onMounted(() => {
12+ if (!homeInv.value) refresh()
13+})
14+
15+interface Inherited { key: string, type: ItemType, name: string, agents: AgentId[], plugin: boolean, hideable: boolean }
16+
17+const all = computed<Inherited[]>(() => {
18+ const map = new Map<string, Inherited>()
19+ for (const i of homeInv.value?.items ?? []) {
20+ if (i.type === 'instructions') continue
21+ const name = itemDisplayName(i)
22+ const key = `${i.type}/${name}`
23+ const e = map.get(key) ?? { key, type: i.type, name, agents: [], plugin: !!i.source.plugin, hideable: i.type === 'skill' || i.type === 'mcp' }
24+ if (!e.agents.includes(i.agent)) e.agents.push(i.agent)
25+ e.plugin = e.plugin && !!i.source.plugin
26+ map.set(key, e)
27+ }
28+ const order: ItemType[] = ['skill', 'mcp', 'rule', 'hook', 'agent']
29+ return [...map.values()].sort((a, b) => order.indexOf(a.type) - order.indexOf(b.type) || a.name.localeCompare(b.name))
30+})
31+
32+const search = ref('')
33+const showFixed = ref(false)
34+const LIMIT = 40
35+const showAllHideable = ref(false)
36+
37+const filtered = computed(() => {
38+ const q = search.value.trim().toLowerCase()
39+ return all.value.filter(i => !q || i.name.toLowerCase().includes(q))
40+})
41+const hideable = computed(() => filtered.value.filter(i => i.hideable))
42+const fixed = computed(() => filtered.value.filter(i => !i.hideable))
43+const shownHideable = computed(() => (showAllHideable.value ? hideable.value : hideable.value.slice(0, LIMIT)))
44+
45+const listFor = (t: ItemType) => (t === 'skill' ? 'skills' : 'mcp') as 'skills' | 'mcp'
46+const hiddenMatch = (i: Inherited) => (i.hideable ? matchIn(scope.config.value?.hideGlobal[listFor(i.type)], i.name) : null)
47+
48+function note(i: Inherited): string {
49+ const noun = i.type === 'mcp' ? 'MCP servers' : 'skills'
50+ if (!i.hideable) return 'Can\'t be hidden'
51+ if (i.plugin) return 'Provided by a plugin'
52+ const hidden = !!hiddenMatch(i)
53+ const claude = i.agents.includes('claude')
54+ const codex = i.agents.includes('codex')
55+ if (!claude) return `Codex can't hide ${noun}`
56+ if (hidden) return codex ? 'Hidden for Claude Code · Codex can\'t hide it' : 'Hidden here'
57+ return codex ? 'Visible · Codex can\'t hide it' : 'Visible'
58+}
59+
60+const canSwitch = (i: Inherited) => i.hideable && !i.plugin && i.agents.includes('claude') && (hiddenMatch(i)?.exact ?? true)
61+
62+function setHidden(i: Inherited, on: boolean) {
63+ scope.edit((c) => {
64+ const k = listFor(i.type)
65+ c.hideGlobal[k] = toggleIn(c.hideGlobal[k], i.name, on)
66+ })
67+}
68+</script>
69+
70+<template>
71+ <SkSection title="Inherited from Home">
72+ <template #actions>
73+ <span class="text-pretty text-sk-muted">Hiding is a fallback. Agents support it only partially.</span>
74+ <NuxtLink to="/home" class="text-sk-accent-text hover:underline">Move it out of Home instead</NuxtLink>
75+ </template>
76+
77+ <SkPanel>
78+ <div v-if="all.length > 8" class="border-b border-sk-line2 px-4 py-2.5">
79+ <UInput v-model="search" placeholder="Search Home items" size="sm" class="w-full max-w-xs" />
80+ </div>
81+
82+ <SkRow v-for="i in shownHideable" :key="i.key" cols="minmax(0,1fr) auto minmax(0,220px) 28px" class="text-[13px]">
83+ <span class="flex min-w-0 items-baseline gap-2"><span class="truncate font-medium">{{ i.name }}</span><SkTypeTag :type="i.type" /></span>
84+ <span class="text-sk-muted">{{ i.agents.map(a => AGENT_LABELS[a]).join(', ') }}</span>
85+ <span class="text-right text-xs text-sk-muted">{{ note(i) }}</span>
86+ <span class="inline-flex justify-end" :class="!canSwitch(i) && 'opacity-40'">
87+ <USwitch
88+ :model-value="!!hiddenMatch(i)"
89+ :disabled="!canSwitch(i)"
90+ :aria-label="`Hide ${i.name} in this project`"
91+ @update:model-value="(v: boolean) => setHidden(i, v)"
92+ />
93+ </span>
94+ </SkRow>
95+
96+ <div v-if="hideable.length > shownHideable.length" class="border-t border-sk-line2 px-4 py-2.5 text-center">
97+ <SkLinkAction @click="showAllHideable = true">Show all {{ hideable.length }} skills and MCP servers</SkLinkAction>
98+ </div>
99+
100+ <template v-if="showFixed || search">
101+ <SkRow v-for="i in fixed" :key="i.key" cols="minmax(0,1fr) auto minmax(0,220px) 28px" class="text-[13px]">
102+ <span class="flex min-w-0 items-baseline gap-2"><span class="truncate font-medium">{{ i.name }}</span><SkTypeTag :type="i.type" /></span>
103+ <span class="text-sk-muted">{{ i.agents.map(a => AGENT_LABELS[a]).join(', ') }}</span>
104+ <span class="text-right text-xs text-sk-muted">{{ note(i) }}</span>
105+ <span class="inline-flex justify-end opacity-40"><USwitch :model-value="false" disabled :aria-label="`${i.name} can't be hidden`" /></span>
106+ </SkRow>
107+ </template>
108+ <div v-if="fixed.length && !showFixed && !search" class="border-t border-sk-line2 px-4 py-2.5 text-center">
109+ <SkLinkAction @click="showFixed = true">Show {{ fixed.length }} rules, hooks and subagents that can't be hidden</SkLinkAction>
110+ </div>
111+
112+ <SkEmpty v-if="!filtered.length && !loading">{{ search ? 'No Home item matches your search.' : 'Nothing is installed in Home: there is nothing to inherit.' }}</SkEmpty>
113+ <div v-if="loading && !homeInv" class="p-4"><USkeleton class="h-8 w-full" /></div>
114+ </SkPanel>
115+ </SkSection>
116+</template>
added packages/ui/app/components/scope/ScopeInstructionsTab.vue +78 -0
new file mode 100644
@@ -0,0 +1,78 @@
1+<script setup lang="ts">
2+import type { InstructionsDetails, InventoryItem } from '@skilly/core'
3+
4+/**
5+ * Instructions tab (mockup 12): CLAUDE.md / AGENTS.md of the project, read-only, with loaded / not loaded,
6+ * imports, size, and the Codex 32 KiB limit gauge (brief 3, "Project instructions").
7+ */
8+const scope = useScopeContext()
9+
10+const CODEX_LIMIT = 32 * 1024
11+const detail = (i: InventoryItem) => i.details as InstructionsDetails
12+
13+/** One entry per file: AGENTS.md is scanned for both agents but is the same file on disk. */
14+interface InstructionFile { id: string, item: InventoryItem, loadedBy: InventoryItem[], notLoaded: InventoryItem[] }
15+const files = computed<InstructionFile[]>(() => {
16+ const byPath = new Map<string, InstructionFile>()
17+ for (const i of (scope.inventory.value?.items ?? []).filter(x => x.type === 'instructions')) {
18+ const key = i.source.path.toLowerCase()
19+ const e = byPath.get(key) ?? { id: i.id, item: i, loadedBy: [], notLoaded: [] }
20+ ;(detail(i).loaded ? e.loadedBy : e.notLoaded).push(i)
21+ byPath.set(key, e)
22+ }
23+ return [...byPath.values()]
24+})
25+
26+const selected = ref<string>('')
27+watch(files, (list) => {
28+ if (!list.some(f => f.id === selected.value)) selected.value = list[0]?.id ?? ''
29+}, { immediate: true })
30+const current = computed(() => files.value.find(f => f.id === selected.value))
31+
32+const options = computed(() => files.value.map(f => ({
33+ value: f.id,
34+ label: detail(f.item).fileName,
35+ title: f.loadedBy.length ? undefined : detail(f.item).notLoadedReason ?? 'Not loaded'
36+})))
37+const gauge = computed(() => {
38+ const f = current.value
39+ if (!f || !f.loadedBy.concat(f.notLoaded).some(i => i.agent === 'codex' && detail(i).fileName.startsWith('AGENTS'))) return null
40+ const size = detail(f.item).sizeBytes
41+ return { size, pct: Math.min(100, Math.round((size / CODEX_LIMIT) * 100)), over: size > CODEX_LIMIT }
42+})
43+const loadedText = (f: InstructionFile) => (f.loadedBy.length ? `Loaded by ${f.loadedBy.map(i => AGENT_LABELS[i.agent]).join(' and ')}` : 'Not loaded')
44+</script>
45+
46+<template>
47+ <div class="flex flex-col gap-3.5">
48+ <div v-if="files.length" class="flex flex-wrap items-center justify-between gap-x-3 gap-y-2">
49+ <SkSeg v-model="selected" :options="options" mono aria-label="Instruction file" />
50+ <span class="text-sk-muted">Read-only · specific to this project, never shared</span>
51+ </div>
52+
53+ <template v-if="current">
54+ <SkNote v-for="n in current.notLoaded" :key="n.id" tone="faint">
55+ <span class="font-medium">Not loaded by {{ AGENT_LABELS[n.agent] }}.</span> {{ detail(n).notLoadedReason ?? `${AGENT_LABELS[n.agent]} does not read this file here.` }}
56+ </SkNote>
57+ <div class="flex flex-wrap items-center gap-x-4 gap-y-1.5 text-xs text-sk-muted">
58+ <SkStatus :tone="current.loadedBy.length ? 'ok' : 'faint'" :label="loadedText(current)" small class="text-sk-muted" />
59+ <span class="font-mono">{{ formatBytes(detail(current.item).sizeBytes) }}</span>
60+ <span v-if="detail(current.item).imports.length" class="font-mono">imports {{ detail(current.item).imports.join(', ') }}</span>
61+ <span class="truncate font-mono text-sk-faint" :title="current.item.source.path">{{ pathInRoot(scope.rootPath.value, current.item.source.path) }}</span>
62+ </div>
63+ <div v-if="gauge" class="flex items-center gap-3 text-xs" :class="gauge.over ? 'text-sk-accent-text' : 'text-sk-muted'">
64+ <span class="h-1.5 w-40 overflow-hidden rounded-full bg-sk-line2" role="img" :aria-label="`${gauge.pct}% of the Codex limit`">
65+ <span class="block h-full rounded-full" :class="gauge.over ? 'bg-sk-accent' : 'bg-sk-info'" :style="{ width: `${gauge.pct}%` }" />
66+ </span>
67+ <span class="font-mono">{{ formatBytes(gauge.size) }} of 32 KB read by Codex</span>
68+ </div>
69+ <SkPanel padded>
70+ <ScopeInstructionsViewer :path="current.item.source.path" />
71+ </SkPanel>
72+ </template>
73+
74+ <SkPanel v-else>
75+ <SkEmpty>No CLAUDE.md or AGENTS.md in this project. Instruction files are written by you: skilly only reads them.</SkEmpty>
76+ </SkPanel>
77+ </div>
78+</template>
new file mode 100644
@@ -0,0 +1,78 @@
1+<script setup lang="ts">
2+import type { InstructionsDetails, InventoryItem } from '@skilly/core'
3+
4+/**
5+ * Instructions tab (mockup 12): CLAUDE.md / AGENTS.md of the project, read-only, with loaded / not loaded,
6+ * imports, size, and the Codex 32 KiB limit gauge (brief 3, "Project instructions").
7+ */
8+const scope = useScopeContext()
9+
10+const CODEX_LIMIT = 32 * 1024
11+const detail = (i: InventoryItem) => i.details as InstructionsDetails
12+
13+/** One entry per file: AGENTS.md is scanned for both agents but is the same file on disk. */
14+interface InstructionFile { id: string, item: InventoryItem, loadedBy: InventoryItem[], notLoaded: InventoryItem[] }
15+const files = computed<InstructionFile[]>(() => {
16+ const byPath = new Map<string, InstructionFile>()
17+ for (const i of (scope.inventory.value?.items ?? []).filter(x => x.type === 'instructions')) {
18+ const key = i.source.path.toLowerCase()
19+ const e = byPath.get(key) ?? { id: i.id, item: i, loadedBy: [], notLoaded: [] }
20+ ;(detail(i).loaded ? e.loadedBy : e.notLoaded).push(i)
21+ byPath.set(key, e)
22+ }
23+ return [...byPath.values()]
24+})
25+
26+const selected = ref<string>('')
27+watch(files, (list) => {
28+ if (!list.some(f => f.id === selected.value)) selected.value = list[0]?.id ?? ''
29+}, { immediate: true })
30+const current = computed(() => files.value.find(f => f.id === selected.value))
31+
32+const options = computed(() => files.value.map(f => ({
33+ value: f.id,
34+ label: detail(f.item).fileName,
35+ title: f.loadedBy.length ? undefined : detail(f.item).notLoadedReason ?? 'Not loaded'
36+})))
37+const gauge = computed(() => {
38+ const f = current.value
39+ if (!f || !f.loadedBy.concat(f.notLoaded).some(i => i.agent === 'codex' && detail(i).fileName.startsWith('AGENTS'))) return null
40+ const size = detail(f.item).sizeBytes
41+ return { size, pct: Math.min(100, Math.round((size / CODEX_LIMIT) * 100)), over: size > CODEX_LIMIT }
42+})
43+const loadedText = (f: InstructionFile) => (f.loadedBy.length ? `Loaded by ${f.loadedBy.map(i => AGENT_LABELS[i.agent]).join(' and ')}` : 'Not loaded')
44+</script>
45+
46+<template>
47+ <div class="flex flex-col gap-3.5">
48+ <div v-if="files.length" class="flex flex-wrap items-center justify-between gap-x-3 gap-y-2">
49+ <SkSeg v-model="selected" :options="options" mono aria-label="Instruction file" />
50+ <span class="text-sk-muted">Read-only · specific to this project, never shared</span>
51+ </div>
52+
53+ <template v-if="current">
54+ <SkNote v-for="n in current.notLoaded" :key="n.id" tone="faint">
55+ <span class="font-medium">Not loaded by {{ AGENT_LABELS[n.agent] }}.</span> {{ detail(n).notLoadedReason ?? `${AGENT_LABELS[n.agent]} does not read this file here.` }}
56+ </SkNote>
57+ <div class="flex flex-wrap items-center gap-x-4 gap-y-1.5 text-xs text-sk-muted">
58+ <SkStatus :tone="current.loadedBy.length ? 'ok' : 'faint'" :label="loadedText(current)" small class="text-sk-muted" />
59+ <span class="font-mono">{{ formatBytes(detail(current.item).sizeBytes) }}</span>
60+ <span v-if="detail(current.item).imports.length" class="font-mono">imports {{ detail(current.item).imports.join(', ') }}</span>
61+ <span class="truncate font-mono text-sk-faint" :title="current.item.source.path">{{ pathInRoot(scope.rootPath.value, current.item.source.path) }}</span>
62+ </div>
63+ <div v-if="gauge" class="flex items-center gap-3 text-xs" :class="gauge.over ? 'text-sk-accent-text' : 'text-sk-muted'">
64+ <span class="h-1.5 w-40 overflow-hidden rounded-full bg-sk-line2" role="img" :aria-label="`${gauge.pct}% of the Codex limit`">
65+ <span class="block h-full rounded-full" :class="gauge.over ? 'bg-sk-accent' : 'bg-sk-info'" :style="{ width: `${gauge.pct}%` }" />
66+ </span>
67+ <span class="font-mono">{{ formatBytes(gauge.size) }} of 32 KB read by Codex</span>
68+ </div>
69+ <SkPanel padded>
70+ <ScopeInstructionsViewer :path="current.item.source.path" />
71+ </SkPanel>
72+ </template>
73+
74+ <SkPanel v-else>
75+ <SkEmpty>No CLAUDE.md or AGENTS.md in this project. Instruction files are written by you: skilly only reads them.</SkEmpty>
76+ </SkPanel>
77+ </div>
78+</template>
added packages/ui/app/components/scope/ScopeInstructionsViewer.vue +47 -0
new file mode 100644
@@ -0,0 +1,47 @@
1+<script setup lang="ts">
2+import type { FileContent } from '@skilly/core'
3+
4+/**
5+ * Read-only view of an instruction file (CLAUDE.md, AGENTS.md...) in a mono block.
6+ * The block managed by skilly (between `<!-- skilly:begin -->` and `<!-- skilly:end -->`) is highlighted.
7+ */
8+const props = defineProps<{ path: string }>()
9+const { request } = useApi()
10+
11+const file = ref<FileContent | null>(null)
12+const loading = ref(false)
13+const error = ref<string | null>(null)
14+
15+async function load() {
16+ loading.value = true
17+ error.value = null
18+ file.value = null
19+ try {
20+ file.value = await request<FileContent>('/api/files', { query: { path: props.path }, silent: true })
21+ } catch (e) {
22+ error.value = (e as Error).message
23+ } finally {
24+ loading.value = false
25+ }
26+}
27+watch(() => props.path, load, { immediate: true })
28+
29+const segments = computed(() => splitManagedBlock(file.value?.content ?? ''))
30+const hasManaged = computed(() => segments.value.some(s => s.managed))
31+</script>
32+
33+<template>
34+ <div class="flex flex-col gap-2.5">
35+ <div v-if="loading" class="flex flex-col gap-2"><USkeleton class="h-4 w-1/3" /><USkeleton class="h-4 w-full" /><USkeleton class="h-4 w-5/6" /></div>
36+ <SkNote v-else-if="error" tone="accent">Could not read the file: {{ error }}</SkNote>
37+ <template v-else-if="file">
38+ <SkNote v-if="file.truncated">Only the first part of this file is shown ({{ formatBytes(file.sizeBytes) }} in total).</SkNote>
39+ <p v-if="hasManaged" class="m-0 flex items-center gap-2 text-xs text-sk-muted">
40+ <span class="inline-block h-3 w-1 rounded-full bg-sk-accent" aria-hidden="true" />
41+ The highlighted block is generated by skilly from your enabled rules. Edit the rules, not the block.
42+ </p>
43+ <p v-if="!file.content.trim()" class="m-0 py-6 text-center text-sk-faint">This file is empty.</p>
44+ <pre v-else class="m-0 overflow-auto font-mono text-xs leading-[1.7] break-words whitespace-pre-wrap text-sk-text"><template v-for="(s, i) in segments" :key="i"><span v-if="s.managed" class="-mx-3 block rounded-[6px] border-l-2 border-sk-accent bg-sk-accent-bg px-[10px]">{{ s.text }}</span><template v-else>{{ s.text }}</template></template></pre>
45+ </template>
46+ </div>
47+</template>
new file mode 100644
@@ -0,0 +1,47 @@
1+<script setup lang="ts">
2+import type { FileContent } from '@skilly/core'
3+
4+/**
5+ * Read-only view of an instruction file (CLAUDE.md, AGENTS.md...) in a mono block.
6+ * The block managed by skilly (between `<!-- skilly:begin -->` and `<!-- skilly:end -->`) is highlighted.
7+ */
8+const props = defineProps<{ path: string }>()
9+const { request } = useApi()
10+
11+const file = ref<FileContent | null>(null)
12+const loading = ref(false)
13+const error = ref<string | null>(null)
14+
15+async function load() {
16+ loading.value = true
17+ error.value = null
18+ file.value = null
19+ try {
20+ file.value = await request<FileContent>('/api/files', { query: { path: props.path }, silent: true })
21+ } catch (e) {
22+ error.value = (e as Error).message
23+ } finally {
24+ loading.value = false
25+ }
26+}
27+watch(() => props.path, load, { immediate: true })
28+
29+const segments = computed(() => splitManagedBlock(file.value?.content ?? ''))
30+const hasManaged = computed(() => segments.value.some(s => s.managed))
31+</script>
32+
33+<template>
34+ <div class="flex flex-col gap-2.5">
35+ <div v-if="loading" class="flex flex-col gap-2"><USkeleton class="h-4 w-1/3" /><USkeleton class="h-4 w-full" /><USkeleton class="h-4 w-5/6" /></div>
36+ <SkNote v-else-if="error" tone="accent">Could not read the file: {{ error }}</SkNote>
37+ <template v-else-if="file">
38+ <SkNote v-if="file.truncated">Only the first part of this file is shown ({{ formatBytes(file.sizeBytes) }} in total).</SkNote>
39+ <p v-if="hasManaged" class="m-0 flex items-center gap-2 text-xs text-sk-muted">
40+ <span class="inline-block h-3 w-1 rounded-full bg-sk-accent" aria-hidden="true" />
41+ The highlighted block is generated by skilly from your enabled rules. Edit the rules, not the block.
42+ </p>
43+ <p v-if="!file.content.trim()" class="m-0 py-6 text-center text-sk-faint">This file is empty.</p>
44+ <pre v-else class="m-0 overflow-auto font-mono text-xs leading-[1.7] break-words whitespace-pre-wrap text-sk-text"><template v-for="(s, i) in segments" :key="i"><span v-if="s.managed" class="-mx-3 block rounded-[6px] border-l-2 border-sk-accent bg-sk-accent-bg px-[10px]">{{ s.text }}</span><template v-else>{{ s.text }}</template></template></pre>
45+ </template>
46+ </div>
47+</template>
added packages/ui/app/components/scope/ScopeInventoryList.vue +149 -0
new file mode 100644
@@ -0,0 +1,149 @@
1+<script setup lang="ts">
2+import type { AgentId, InstructionsDetails, InventoryItem, ItemStatus, ItemType, McpDetails } from '@skilly/core'
3+import type { ProjectSpecific } from '../../utils/inventoryView'
4+import type { Tone } from '../../utils/status'
5+
6+/**
7+ * Raw inventory grouped by type in a tray of row cards (Home inventory, mockup 6; project Files tab).
8+ * Filters: type segment with counts, agent segment, search. One row per agent and file, as the paths differ.
9+ * Home rows get "Move" (skills, rules, subagents) or "Add to catalog" (MCP servers and hooks are read-only in v1).
10+ */
11+const props = defineProps<{
12+ items: InventoryItem[]
13+ statuses: Record<string, ItemStatus>
14+ root: string
15+ mode: 'home' | 'project'
16+ /** Items flagged "Project-specific" (Home), by inventory item id. */
17+ flagged?: Map<string, ProjectSpecific>
18+ /** Restricts the list to these item ids ("Review" in the suggestion panel). */
19+ onlyIds?: Set<string> | null
20+}>()
21+const emit = defineEmits<{ select: [item: InventoryItem], move: [item: InventoryItem], import: [item: InventoryItem] }>()
22+
23+const home = computed(() => props.mode === 'home')
24+const typeFilter = ref<'all' | ItemType>('all')
25+const agentFilter = ref<'all' | AgentId>('all')
26+const search = ref('')
27+
28+const GROUP_ORDER: ItemType[] = ['skill', 'rule', 'mcp', 'hook', 'agent', 'instructions']
29+
30+function haystack(i: InventoryItem): string {
31+ const d = i.details
32+ return [
33+ itemDisplayName(i), i.description, i.source.path, i.source.plugin,
34+ d.type === 'mcp' ? `${d.command ?? ''} ${d.url ?? ''} ${d.transport}` : '',
35+ d.type === 'hook' ? `${d.event} ${d.matcher ?? ''} ${d.command ?? ''}` : '',
36+ d.type === 'agent' ? d.model : ''
37+ ].join(' ').toLowerCase()
38+}
39+
40+const base = computed(() => {
41+ const q = search.value.trim().toLowerCase()
42+ return props.items.filter(i =>
43+ (agentFilter.value === 'all' || i.agent === agentFilter.value)
44+ && (!q || haystack(i).includes(q))
45+ && (!props.onlyIds || props.onlyIds.has(i.id)))
46+})
47+
48+const counts = computed(() => {
49+ const c = Object.fromEntries(GROUP_ORDER.map(t => [t, 0])) as Record<ItemType, number>
50+ for (const i of base.value) c[i.type]++
51+ return c
52+})
53+const totalNoInstr = computed(() => GROUP_ORDER.filter(t => t !== 'instructions').reduce((n, t) => n + counts.value[t], 0))
54+
55+const typeOptions = computed(() => [
56+ { value: 'all' as const, label: 'All', count: totalNoInstr.value },
57+ ...GROUP_ORDER.filter(t => t !== 'instructions').map(t => ({ value: t, label: TYPE_PLURAL[t], count: counts.value[t] })),
58+ ...(counts.value.instructions || typeFilter.value === 'instructions' ? [{ value: 'instructions' as const, label: 'Instructions', count: counts.value.instructions }] : [])
59+])
60+const agentOptions = [
61+ { value: 'all' as const, label: 'All agents' },
62+ { value: 'claude' as const, label: AGENT_LABELS.claude },
63+ { value: 'codex' as const, label: AGENT_LABELS.codex }
64+]
65+
66+const groups = computed(() => {
67+ const by = Object.fromEntries(GROUP_ORDER.map(t => [t, [] as InventoryItem[]])) as Record<ItemType, InventoryItem[]>
68+ for (const i of base.value) by[i.type].push(i)
69+ // Sort by name, then agent; hooks and instruction files keep their scan order (it is meaningful).
70+ for (const t of ['skill', 'rule', 'mcp', 'agent'] as const) {
71+ by[t].sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }) || a.agent.localeCompare(b.agent))
72+ }
73+ return GROUP_ORDER
74+ .filter(t => (typeFilter.value === 'all' ? true : typeFilter.value === t) && by[t].length)
75+ .map(t => ({ type: t, items: by[t] }))
76+})
77+const empty = computed(() => !groups.value.length)
78+
79+const stateOf = (i: InventoryItem): { tone: Tone, label: string } => {
80+ if (i.type === 'instructions') {
81+ const d = i.details as InstructionsDetails
82+ return { tone: d.loaded ? 'ok' : 'faint', label: d.loaded ? 'Loaded' : 'Not loaded' }
83+ }
84+ const st = props.statuses[i.id]
85+ if (home.value) return homeStateMeta(st?.state)
86+ const meta = ITEM_STATE_META[st?.state ?? 'not-in-catalog']
87+ return { tone: meta.tone, label: meta.label }
88+}
89+
90+const mcpDisabled = (i: InventoryItem) => i.details.type === 'mcp' && !(i.details as McpDetails).enabled
91+const readOnly = (t: ItemType) => home.value && (t === 'mcp' || t === 'hook')
92+</script>
93+
94+<template>
95+ <div class="flex min-w-0 flex-col gap-3">
96+ <div class="flex flex-wrap items-center gap-x-3 gap-y-2">
97+ <SkSeg v-model="typeFilter" :options="typeOptions" size="md" aria-label="Filter by type" />
98+ <UInput v-model="search" placeholder="Search items" size="sm" class="min-w-40 flex-1" aria-label="Search items" />
99+ <SkSeg v-model="agentFilter" :options="agentOptions" size="md" aria-label="Filter by agent" />
100+ </div>
101+
102+ <SkTray>
103+ <slot name="top" />
104+ <template v-for="g in groups" :key="g.type">
105+ <SkSectionLabel inset :count="g.items.length">
106+ {{ g.type === 'instructions' ? 'Instruction files' : TYPE_PLURAL[g.type] }}
107+ <span v-if="readOnly(g.type)" class="normal-case tracking-normal" title="Entries of your user configuration files: shown read-only in v1. You can still add them to the catalog.">· read-only in v1</span>
108+ </SkSectionLabel>
109+ <SkRowCard
110+ v-for="i in g.items"
111+ :key="i.id"
112+ interactive
113+ cols="minmax(0,1fr) 100px 148px 118px"
114+ tabindex="0"
115+ role="button"
116+ :aria-label="`Open ${itemDisplayName(i)} (${AGENT_LABELS[i.agent]})`"
117+ @click="emit('select', i)"
118+ @keydown.enter="emit('select', i)"
119+ >
120+ <div class="min-w-0">
121+ <div class="flex flex-wrap items-baseline gap-x-2 gap-y-0.5">
122+ <span class="truncate font-medium">{{ itemDisplayName(i) }}</span>
123+ <span v-if="flagged?.has(i.id)" class="text-[11.5px] font-medium text-sk-accent-text" :title="flagged.get(i.id)!.hint">Project-specific</span>
124+ <SkTag v-if="i.source.plugin" mono :title="`Provided by the plugin ${i.source.plugin}`">plugin</SkTag>
125+ <SkTag v-if="i.source.symlinkTarget" mono :title="`Symlink to ${i.source.symlinkTarget}`">symlink</SkTag>
126+ <SkTag v-if="mcpDisabled(i)" mono>disabled</SkTag>
127+ <span v-if="i.warnings.length" class="font-mono text-[11px] text-sk-accent-text" :title="i.warnings.join('\n')">warning</span>
128+ </div>
129+ <div class="truncate font-mono text-[11px] text-sk-faint" :title="itemLocation(i, root, home ? '~/' : '')">{{ itemLocation(i, root, home ? '~/' : '') }}</div>
130+ </div>
131+ <SkAgentBadge :agent="i.agent" />
132+ <SkStatus :tone="stateOf(i).tone" :label="stateOf(i).label" small class="text-sk-muted" />
133+ <div class="flex justify-end" @click.stop>
134+ <template v-if="home">
135+ <SkButton v-if="canMoveOutOfHome(i, statuses[i.id])" variant="primary" size="sm" label="Move" :aria-label="`Move ${i.name} out of Home`" @click="emit('move', i)" />
136+ <SkButton v-else-if="canAddToCatalog(i, statuses[i.id])" size="sm" label="Add to catalog" @click="emit('import', i)" />
137+ <span v-else-if="statuses[i.id]?.managed" class="font-mono text-[11px] text-sk-faint">managed</span>
138+ <span v-else-if="i.source.plugin" class="font-mono text-[11px] text-sk-faint">plugin</span>
139+ </template>
140+ <SkButton v-else-if="canAddToCatalog(i, statuses[i.id])" size="sm" label="Add to catalog" @click="emit('import', i)" />
141+ </div>
142+ </SkRowCard>
143+ </template>
144+ <SkEmpty v-if="empty" class="rounded-[11px] bg-sk-panel">
145+ {{ items.length ? 'Nothing matches these filters.' : 'No item found here.' }}
146+ </SkEmpty>
147+ </SkTray>
148+ </div>
149+</template>
new file mode 100644
@@ -0,0 +1,149 @@
1+<script setup lang="ts">
2+import type { AgentId, InstructionsDetails, InventoryItem, ItemStatus, ItemType, McpDetails } from '@skilly/core'
3+import type { ProjectSpecific } from '../../utils/inventoryView'
4+import type { Tone } from '../../utils/status'
5+
6+/**
7+ * Raw inventory grouped by type in a tray of row cards (Home inventory, mockup 6; project Files tab).
8+ * Filters: type segment with counts, agent segment, search. One row per agent and file, as the paths differ.
9+ * Home rows get "Move" (skills, rules, subagents) or "Add to catalog" (MCP servers and hooks are read-only in v1).
10+ */
11+const props = defineProps<{
12+ items: InventoryItem[]
13+ statuses: Record<string, ItemStatus>
14+ root: string
15+ mode: 'home' | 'project'
16+ /** Items flagged "Project-specific" (Home), by inventory item id. */
17+ flagged?: Map<string, ProjectSpecific>
18+ /** Restricts the list to these item ids ("Review" in the suggestion panel). */
19+ onlyIds?: Set<string> | null
20+}>()
21+const emit = defineEmits<{ select: [item: InventoryItem], move: [item: InventoryItem], import: [item: InventoryItem] }>()
22+
23+const home = computed(() => props.mode === 'home')
24+const typeFilter = ref<'all' | ItemType>('all')
25+const agentFilter = ref<'all' | AgentId>('all')
26+const search = ref('')
27+
28+const GROUP_ORDER: ItemType[] = ['skill', 'rule', 'mcp', 'hook', 'agent', 'instructions']
29+
30+function haystack(i: InventoryItem): string {
31+ const d = i.details
32+ return [
33+ itemDisplayName(i), i.description, i.source.path, i.source.plugin,
34+ d.type === 'mcp' ? `${d.command ?? ''} ${d.url ?? ''} ${d.transport}` : '',
35+ d.type === 'hook' ? `${d.event} ${d.matcher ?? ''} ${d.command ?? ''}` : '',
36+ d.type === 'agent' ? d.model : ''
37+ ].join(' ').toLowerCase()
38+}
39+
40+const base = computed(() => {
41+ const q = search.value.trim().toLowerCase()
42+ return props.items.filter(i =>
43+ (agentFilter.value === 'all' || i.agent === agentFilter.value)
44+ && (!q || haystack(i).includes(q))
45+ && (!props.onlyIds || props.onlyIds.has(i.id)))
46+})
47+
48+const counts = computed(() => {
49+ const c = Object.fromEntries(GROUP_ORDER.map(t => [t, 0])) as Record<ItemType, number>
50+ for (const i of base.value) c[i.type]++
51+ return c
52+})
53+const totalNoInstr = computed(() => GROUP_ORDER.filter(t => t !== 'instructions').reduce((n, t) => n + counts.value[t], 0))
54+
55+const typeOptions = computed(() => [
56+ { value: 'all' as const, label: 'All', count: totalNoInstr.value },
57+ ...GROUP_ORDER.filter(t => t !== 'instructions').map(t => ({ value: t, label: TYPE_PLURAL[t], count: counts.value[t] })),
58+ ...(counts.value.instructions || typeFilter.value === 'instructions' ? [{ value: 'instructions' as const, label: 'Instructions', count: counts.value.instructions }] : [])
59+])
60+const agentOptions = [
61+ { value: 'all' as const, label: 'All agents' },
62+ { value: 'claude' as const, label: AGENT_LABELS.claude },
63+ { value: 'codex' as const, label: AGENT_LABELS.codex }
64+]
65+
66+const groups = computed(() => {
67+ const by = Object.fromEntries(GROUP_ORDER.map(t => [t, [] as InventoryItem[]])) as Record<ItemType, InventoryItem[]>
68+ for (const i of base.value) by[i.type].push(i)
69+ // Sort by name, then agent; hooks and instruction files keep their scan order (it is meaningful).
70+ for (const t of ['skill', 'rule', 'mcp', 'agent'] as const) {
71+ by[t].sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }) || a.agent.localeCompare(b.agent))
72+ }
73+ return GROUP_ORDER
74+ .filter(t => (typeFilter.value === 'all' ? true : typeFilter.value === t) && by[t].length)
75+ .map(t => ({ type: t, items: by[t] }))
76+})
77+const empty = computed(() => !groups.value.length)
78+
79+const stateOf = (i: InventoryItem): { tone: Tone, label: string } => {
80+ if (i.type === 'instructions') {
81+ const d = i.details as InstructionsDetails
82+ return { tone: d.loaded ? 'ok' : 'faint', label: d.loaded ? 'Loaded' : 'Not loaded' }
83+ }
84+ const st = props.statuses[i.id]
85+ if (home.value) return homeStateMeta(st?.state)
86+ const meta = ITEM_STATE_META[st?.state ?? 'not-in-catalog']
87+ return { tone: meta.tone, label: meta.label }
88+}
89+
90+const mcpDisabled = (i: InventoryItem) => i.details.type === 'mcp' && !(i.details as McpDetails).enabled
91+const readOnly = (t: ItemType) => home.value && (t === 'mcp' || t === 'hook')
92+</script>
93+
94+<template>
95+ <div class="flex min-w-0 flex-col gap-3">
96+ <div class="flex flex-wrap items-center gap-x-3 gap-y-2">
97+ <SkSeg v-model="typeFilter" :options="typeOptions" size="md" aria-label="Filter by type" />
98+ <UInput v-model="search" placeholder="Search items" size="sm" class="min-w-40 flex-1" aria-label="Search items" />
99+ <SkSeg v-model="agentFilter" :options="agentOptions" size="md" aria-label="Filter by agent" />
100+ </div>
101+
102+ <SkTray>
103+ <slot name="top" />
104+ <template v-for="g in groups" :key="g.type">
105+ <SkSectionLabel inset :count="g.items.length">
106+ {{ g.type === 'instructions' ? 'Instruction files' : TYPE_PLURAL[g.type] }}
107+ <span v-if="readOnly(g.type)" class="normal-case tracking-normal" title="Entries of your user configuration files: shown read-only in v1. You can still add them to the catalog.">· read-only in v1</span>
108+ </SkSectionLabel>
109+ <SkRowCard
110+ v-for="i in g.items"
111+ :key="i.id"
112+ interactive
113+ cols="minmax(0,1fr) 100px 148px 118px"
114+ tabindex="0"
115+ role="button"
116+ :aria-label="`Open ${itemDisplayName(i)} (${AGENT_LABELS[i.agent]})`"
117+ @click="emit('select', i)"
118+ @keydown.enter="emit('select', i)"
119+ >
120+ <div class="min-w-0">
121+ <div class="flex flex-wrap items-baseline gap-x-2 gap-y-0.5">
122+ <span class="truncate font-medium">{{ itemDisplayName(i) }}</span>
123+ <span v-if="flagged?.has(i.id)" class="text-[11.5px] font-medium text-sk-accent-text" :title="flagged.get(i.id)!.hint">Project-specific</span>
124+ <SkTag v-if="i.source.plugin" mono :title="`Provided by the plugin ${i.source.plugin}`">plugin</SkTag>
125+ <SkTag v-if="i.source.symlinkTarget" mono :title="`Symlink to ${i.source.symlinkTarget}`">symlink</SkTag>
126+ <SkTag v-if="mcpDisabled(i)" mono>disabled</SkTag>
127+ <span v-if="i.warnings.length" class="font-mono text-[11px] text-sk-accent-text" :title="i.warnings.join('\n')">warning</span>
128+ </div>
129+ <div class="truncate font-mono text-[11px] text-sk-faint" :title="itemLocation(i, root, home ? '~/' : '')">{{ itemLocation(i, root, home ? '~/' : '') }}</div>
130+ </div>
131+ <SkAgentBadge :agent="i.agent" />
132+ <SkStatus :tone="stateOf(i).tone" :label="stateOf(i).label" small class="text-sk-muted" />
133+ <div class="flex justify-end" @click.stop>
134+ <template v-if="home">
135+ <SkButton v-if="canMoveOutOfHome(i, statuses[i.id])" variant="primary" size="sm" label="Move" :aria-label="`Move ${i.name} out of Home`" @click="emit('move', i)" />
136+ <SkButton v-else-if="canAddToCatalog(i, statuses[i.id])" size="sm" label="Add to catalog" @click="emit('import', i)" />
137+ <span v-else-if="statuses[i.id]?.managed" class="font-mono text-[11px] text-sk-faint">managed</span>
138+ <span v-else-if="i.source.plugin" class="font-mono text-[11px] text-sk-faint">plugin</span>
139+ </template>
140+ <SkButton v-else-if="canAddToCatalog(i, statuses[i.id])" size="sm" label="Add to catalog" @click="emit('import', i)" />
141+ </div>
142+ </SkRowCard>
143+ </template>
144+ <SkEmpty v-if="empty" class="rounded-[11px] bg-sk-panel">
145+ {{ items.length ? 'Nothing matches these filters.' : 'No item found here.' }}
146+ </SkEmpty>
147+ </SkTray>
148+ </div>
149+</template>
added packages/ui/app/components/scope/ScopeItemDrawer.vue +154 -0
new file mode 100644
@@ -0,0 +1,154 @@
1+<script setup lang="ts">
2+import type { SkillDetails } from '@skilly/core'
3+
4+/**
5+ * Item drawer (brief 3, "Item drawer"): metadata grid, warnings, then the file content (rendered markdown) or the
6+ * list of files of a skill; instruction files use the read-only viewer. Actions: open in catalog, add to catalog, move out of Home.
7+ */
8+const scope = useScopeContext()
9+const open = defineModel<boolean>('open', { default: false })
10+const item = computed(() => scope.selectedItem.value)
11+const status = computed(() => (item.value ? scope.statuses.value[item.value.id] : undefined))
12+const home = scope.kind === 'home'
13+
14+const filePath = computed<string | null>(() => {
15+ const it = item.value
16+ if (!it) return null
17+ if (it.type === 'skill') return joinPath(it.source.path, 'SKILL.md')
18+ if (['rule', 'agent', 'instructions'].includes(it.type) && it.source.kind === 'file') return it.source.path
19+ return null
20+})
21+
22+/** Skill file currently displayed (defaults to SKILL.md). */
23+const currentFile = ref<string | null>(null)
24+watch(() => item.value?.id, () => { currentFile.value = null })
25+const shownPath = computed(() => currentFile.value ?? filePath.value)
26+
27+const skillFiles = computed(() => (item.value?.type === 'skill'
28+ ? [...(item.value.details as SkillDetails).files].sort((a, b) => Number(b === 'SKILL.md') - Number(a === 'SKILL.md'))
29+ : []))
30+
31+const frontmatter = computed(() => {
32+ const d = item.value?.details as { frontmatter?: Record<string, unknown> } | undefined
33+ return currentFile.value ? undefined : d?.frontmatter
34+})
35+
36+const display = computed(() => (item.value ? itemDisplayName(item.value) : ''))
37+const stateLabel = computed(() => {
38+ if (!item.value || item.value.type === 'instructions') return null
39+ return home ? homeStateMeta(status.value?.state) : { tone: ITEM_STATE_META[status.value?.state ?? 'not-in-catalog'].tone, label: ITEM_STATE_META[status.value?.state ?? 'not-in-catalog'].label }
40+})
41+
42+interface MetaRow { label: string, value: string, mono?: boolean }
43+const rows = computed<MetaRow[]>(() => {
44+ const it = item.value
45+ if (!it) return []
46+ const r: MetaRow[] = [{ label: 'Path', value: it.source.path, mono: true }]
47+ if (it.source.configKey) r.push({ label: 'Config key', value: it.source.configKey, mono: true })
48+ if (it.source.symlinkTarget) r.push({ label: 'Symlink', value: `Only the link is removed when you move it. Target: ${it.source.symlinkTarget}`, mono: true })
49+ if (it.source.plugin) r.push({ label: 'Plugin', value: it.source.plugin })
50+ if (it.hash) r.push({ label: 'Hash', value: it.hash.slice(0, 12), mono: true })
51+ const d = it.details
52+ if (d.type === 'skill') {
53+ r.push({ label: 'Visibility', value: d.visibility + (d.visibilitySource ? ` · ${d.visibilitySource}` : '') })
54+ } else if (d.type === 'rule') {
55+ r.push({ label: 'Applies to', value: d.paths.length ? d.paths.join(', ') : 'always', mono: true })
56+ } else if (d.type === 'mcp') {
57+ r.push({ label: 'Transport', value: d.transport })
58+ if (d.command) r.push({ label: 'Command', value: [d.command, ...(d.args ?? [])].join(' '), mono: true })
59+ if (d.url) r.push({ label: 'URL', value: d.url, mono: true })
60+ if (d.agentScope) r.push({ label: 'Scope', value: d.agentScope })
61+ r.push({ label: 'Enabled', value: d.enabled ? 'yes' : 'no' })
62+ if (d.envVarNames.length) r.push({ label: 'Env vars', value: d.envVarNames.join(', '), mono: true })
63+ if (d.literalEnvKeys.length) r.push({ label: 'Literal env', value: `${d.literalEnvKeys.join(', ')} (values are never shown)`, mono: true })
64+ } else if (d.type === 'hook') {
65+ r.push({ label: 'Event', value: d.event, mono: true })
66+ if (d.matcher) r.push({ label: 'Matcher', value: d.matcher, mono: true })
67+ r.push({ label: 'Handler', value: d.handlerType })
68+ if (d.command) r.push({ label: 'Command', value: d.command, mono: true })
69+ } else if (d.type === 'agent') {
70+ if (d.model) r.push({ label: 'Model', value: d.model })
71+ if (d.tools?.length) r.push({ label: 'Tools', value: d.tools.join(', '), mono: true })
72+ } else if (d.type === 'instructions') {
73+ r.push({ label: 'Size', value: formatBytes(d.sizeBytes) })
74+ r.push({ label: 'Loaded', value: d.loaded ? 'yes' : `no${d.notLoadedReason ? ` · ${d.notLoadedReason}` : ''}` })
75+ if (d.imports.length) r.push({ label: 'Imports', value: d.imports.join(', '), mono: true })
76+ }
77+ return r
78+})
79+
80+const canCatalog = computed(() => !!item.value && canAddToCatalog(item.value, status.value))
81+const canMove = computed(() => home && !!item.value && canMoveOutOfHome(item.value, status.value))
82+
83+function openInCatalog() {
84+ const ref = status.value?.catalogRef
85+ if (!ref) return
86+ open.value = false
87+ navigateTo(`/catalog/${ref.type}/${encodeURIComponent(ref.name)}`)
88+}
89+function addToCatalog() {
90+ if (!item.value) return
91+ open.value = false
92+ scope.askImport([item.value])
93+}
94+function move() {
95+ if (!item.value) return
96+ open.value = false
97+ scope.askMove([item.value])
98+}
99+</script>
100+
101+<template>
102+ <SkDrawer v-model:open="open" width="lg">
103+ <template #header>
104+ <div v-if="item" class="flex min-w-0 flex-1 flex-col gap-1">
105+ <div class="flex items-baseline gap-2.5">
106+ <h2 class="m-0 truncate text-lg font-semibold tracking-[-0.01em]">{{ display }}</h2>
107+ <SkTypeTag :type="item.type" />
108+ <SkAgentBadge :agent="item.agent" class="text-xs" />
109+ </div>
110+ <p v-if="item.description" class="m-0 line-clamp-3 text-pretty text-sk-muted">{{ item.description }}</p>
111+ <SkStatus v-if="stateLabel" :tone="stateLabel.tone" :label="stateLabel.label" small class="text-xs text-sk-muted" />
112+ </div>
113+ <SkButton variant="ghost" icon="i-lucide-x" aria-label="Close" class="shrink-0" @click="open = false" />
114+ </template>
115+
116+ <div v-if="item" class="flex flex-col gap-5">
117+ <SkNote v-if="item.warnings.length" tone="accent">
118+ <ul class="m-0 flex list-none flex-col gap-1 p-0">
119+ <li v-for="w in item.warnings" :key="w">{{ w }}</li>
120+ </ul>
121+ </SkNote>
122+
123+ <SkMetaGrid label-width="96px">
124+ <SkMeta v-for="r in rows" :key="r.label" :label="r.label" :mono="r.mono"><span class="break-all">{{ r.value }}</span></SkMeta>
125+ </SkMetaGrid>
126+
127+ <div v-if="skillFiles.length > 1" class="flex flex-col gap-2">
128+ <SkSectionLabel :count="skillFiles.length">Files</SkSectionLabel>
129+ <ul class="m-0 flex max-h-44 list-none flex-col overflow-auto rounded-[10px] border border-sk-line2 p-0 font-mono text-xs">
130+ <li v-for="f in skillFiles" :key="f">
131+ <button
132+ type="button"
133+ class="w-full cursor-pointer truncate border-0 bg-transparent px-3 py-1.5 text-left font-mono text-xs text-sk-muted hover:bg-sk-hover"
134+ :class="shownPath === joinPath(item.source.path, f) && 'bg-sk-sel text-sk-text'"
135+ @click="currentFile = f === 'SKILL.md' ? null : joinPath(item.source.path, f)"
136+ >{{ f }}</button>
137+ </li>
138+ </ul>
139+ </div>
140+
141+ <ScopeInstructionsViewer v-if="item.type === 'instructions'" :path="item.source.path" />
142+ <MarkdownView v-else-if="shownPath" :path="shownPath" :frontmatter="frontmatter" />
143+ <p v-else class="m-0 text-sk-faint">This item is an entry of a shared configuration file: there is no separate file to show.</p>
144+ </div>
145+
146+ <template v-if="status?.catalogRef || canCatalog || canMove" #footer>
147+ <div class="flex w-full flex-wrap items-center justify-end gap-2">
148+ <SkButton v-if="status?.catalogRef" label="Open in catalog" @click="openInCatalog" />
149+ <SkButton v-if="canCatalog" label="Add to catalog" @click="addToCatalog" />
150+ <SkButton v-if="canMove" variant="primary" label="Remove from global" @click="move" />
151+ </div>
152+ </template>
153+ </SkDrawer>
154+</template>
new file mode 100644
@@ -0,0 +1,154 @@
1+<script setup lang="ts">
2+import type { SkillDetails } from '@skilly/core'
3+
4+/**
5+ * Item drawer (brief 3, "Item drawer"): metadata grid, warnings, then the file content (rendered markdown) or the
6+ * list of files of a skill; instruction files use the read-only viewer. Actions: open in catalog, add to catalog, move out of Home.
7+ */
8+const scope = useScopeContext()
9+const open = defineModel<boolean>('open', { default: false })
10+const item = computed(() => scope.selectedItem.value)
11+const status = computed(() => (item.value ? scope.statuses.value[item.value.id] : undefined))
12+const home = scope.kind === 'home'
13+
14+const filePath = computed<string | null>(() => {
15+ const it = item.value
16+ if (!it) return null
17+ if (it.type === 'skill') return joinPath(it.source.path, 'SKILL.md')
18+ if (['rule', 'agent', 'instructions'].includes(it.type) && it.source.kind === 'file') return it.source.path
19+ return null
20+})
21+
22+/** Skill file currently displayed (defaults to SKILL.md). */
23+const currentFile = ref<string | null>(null)
24+watch(() => item.value?.id, () => { currentFile.value = null })
25+const shownPath = computed(() => currentFile.value ?? filePath.value)
26+
27+const skillFiles = computed(() => (item.value?.type === 'skill'
28+ ? [...(item.value.details as SkillDetails).files].sort((a, b) => Number(b === 'SKILL.md') - Number(a === 'SKILL.md'))
29+ : []))
30+
31+const frontmatter = computed(() => {
32+ const d = item.value?.details as { frontmatter?: Record<string, unknown> } | undefined
33+ return currentFile.value ? undefined : d?.frontmatter
34+})
35+
36+const display = computed(() => (item.value ? itemDisplayName(item.value) : ''))
37+const stateLabel = computed(() => {
38+ if (!item.value || item.value.type === 'instructions') return null
39+ return home ? homeStateMeta(status.value?.state) : { tone: ITEM_STATE_META[status.value?.state ?? 'not-in-catalog'].tone, label: ITEM_STATE_META[status.value?.state ?? 'not-in-catalog'].label }
40+})
41+
42+interface MetaRow { label: string, value: string, mono?: boolean }
43+const rows = computed<MetaRow[]>(() => {
44+ const it = item.value
45+ if (!it) return []
46+ const r: MetaRow[] = [{ label: 'Path', value: it.source.path, mono: true }]
47+ if (it.source.configKey) r.push({ label: 'Config key', value: it.source.configKey, mono: true })
48+ if (it.source.symlinkTarget) r.push({ label: 'Symlink', value: `Only the link is removed when you move it. Target: ${it.source.symlinkTarget}`, mono: true })
49+ if (it.source.plugin) r.push({ label: 'Plugin', value: it.source.plugin })
50+ if (it.hash) r.push({ label: 'Hash', value: it.hash.slice(0, 12), mono: true })
51+ const d = it.details
52+ if (d.type === 'skill') {
53+ r.push({ label: 'Visibility', value: d.visibility + (d.visibilitySource ? ` · ${d.visibilitySource}` : '') })
54+ } else if (d.type === 'rule') {
55+ r.push({ label: 'Applies to', value: d.paths.length ? d.paths.join(', ') : 'always', mono: true })
56+ } else if (d.type === 'mcp') {
57+ r.push({ label: 'Transport', value: d.transport })
58+ if (d.command) r.push({ label: 'Command', value: [d.command, ...(d.args ?? [])].join(' '), mono: true })
59+ if (d.url) r.push({ label: 'URL', value: d.url, mono: true })
60+ if (d.agentScope) r.push({ label: 'Scope', value: d.agentScope })
61+ r.push({ label: 'Enabled', value: d.enabled ? 'yes' : 'no' })
62+ if (d.envVarNames.length) r.push({ label: 'Env vars', value: d.envVarNames.join(', '), mono: true })
63+ if (d.literalEnvKeys.length) r.push({ label: 'Literal env', value: `${d.literalEnvKeys.join(', ')} (values are never shown)`, mono: true })
64+ } else if (d.type === 'hook') {
65+ r.push({ label: 'Event', value: d.event, mono: true })
66+ if (d.matcher) r.push({ label: 'Matcher', value: d.matcher, mono: true })
67+ r.push({ label: 'Handler', value: d.handlerType })
68+ if (d.command) r.push({ label: 'Command', value: d.command, mono: true })
69+ } else if (d.type === 'agent') {
70+ if (d.model) r.push({ label: 'Model', value: d.model })
71+ if (d.tools?.length) r.push({ label: 'Tools', value: d.tools.join(', '), mono: true })
72+ } else if (d.type === 'instructions') {
73+ r.push({ label: 'Size', value: formatBytes(d.sizeBytes) })
74+ r.push({ label: 'Loaded', value: d.loaded ? 'yes' : `no${d.notLoadedReason ? ` · ${d.notLoadedReason}` : ''}` })
75+ if (d.imports.length) r.push({ label: 'Imports', value: d.imports.join(', '), mono: true })
76+ }
77+ return r
78+})
79+
80+const canCatalog = computed(() => !!item.value && canAddToCatalog(item.value, status.value))
81+const canMove = computed(() => home && !!item.value && canMoveOutOfHome(item.value, status.value))
82+
83+function openInCatalog() {
84+ const ref = status.value?.catalogRef
85+ if (!ref) return
86+ open.value = false
87+ navigateTo(`/catalog/${ref.type}/${encodeURIComponent(ref.name)}`)
88+}
89+function addToCatalog() {
90+ if (!item.value) return
91+ open.value = false
92+ scope.askImport([item.value])
93+}
94+function move() {
95+ if (!item.value) return
96+ open.value = false
97+ scope.askMove([item.value])
98+}
99+</script>
100+
101+<template>
102+ <SkDrawer v-model:open="open" width="lg">
103+ <template #header>
104+ <div v-if="item" class="flex min-w-0 flex-1 flex-col gap-1">
105+ <div class="flex items-baseline gap-2.5">
106+ <h2 class="m-0 truncate text-lg font-semibold tracking-[-0.01em]">{{ display }}</h2>
107+ <SkTypeTag :type="item.type" />
108+ <SkAgentBadge :agent="item.agent" class="text-xs" />
109+ </div>
110+ <p v-if="item.description" class="m-0 line-clamp-3 text-pretty text-sk-muted">{{ item.description }}</p>
111+ <SkStatus v-if="stateLabel" :tone="stateLabel.tone" :label="stateLabel.label" small class="text-xs text-sk-muted" />
112+ </div>
113+ <SkButton variant="ghost" icon="i-lucide-x" aria-label="Close" class="shrink-0" @click="open = false" />
114+ </template>
115+
116+ <div v-if="item" class="flex flex-col gap-5">
117+ <SkNote v-if="item.warnings.length" tone="accent">
118+ <ul class="m-0 flex list-none flex-col gap-1 p-0">
119+ <li v-for="w in item.warnings" :key="w">{{ w }}</li>
120+ </ul>
121+ </SkNote>
122+
123+ <SkMetaGrid label-width="96px">
124+ <SkMeta v-for="r in rows" :key="r.label" :label="r.label" :mono="r.mono"><span class="break-all">{{ r.value }}</span></SkMeta>
125+ </SkMetaGrid>
126+
127+ <div v-if="skillFiles.length > 1" class="flex flex-col gap-2">
128+ <SkSectionLabel :count="skillFiles.length">Files</SkSectionLabel>
129+ <ul class="m-0 flex max-h-44 list-none flex-col overflow-auto rounded-[10px] border border-sk-line2 p-0 font-mono text-xs">
130+ <li v-for="f in skillFiles" :key="f">
131+ <button
132+ type="button"
133+ class="w-full cursor-pointer truncate border-0 bg-transparent px-3 py-1.5 text-left font-mono text-xs text-sk-muted hover:bg-sk-hover"
134+ :class="shownPath === joinPath(item.source.path, f) && 'bg-sk-sel text-sk-text'"
135+ @click="currentFile = f === 'SKILL.md' ? null : joinPath(item.source.path, f)"
136+ >{{ f }}</button>
137+ </li>
138+ </ul>
139+ </div>
140+
141+ <ScopeInstructionsViewer v-if="item.type === 'instructions'" :path="item.source.path" />
142+ <MarkdownView v-else-if="shownPath" :path="shownPath" :frontmatter="frontmatter" />
143+ <p v-else class="m-0 text-sk-faint">This item is an entry of a shared configuration file: there is no separate file to show.</p>
144+ </div>
145+
146+ <template v-if="status?.catalogRef || canCatalog || canMove" #footer>
147+ <div class="flex w-full flex-wrap items-center justify-end gap-2">
148+ <SkButton v-if="status?.catalogRef" label="Open in catalog" @click="openInCatalog" />
149+ <SkButton v-if="canCatalog" label="Add to catalog" @click="addToCatalog" />
150+ <SkButton v-if="canMove" variant="primary" label="Remove from global" @click="move" />
151+ </div>
152+ </template>
153+ </SkDrawer>
154+</template>
added packages/ui/app/components/scope/ScopeItemRow.vue +80 -0
new file mode 100644
@@ -0,0 +1,80 @@
1+<script setup lang="ts">
2+import type { RowAction, ScopeRow } from '../../utils/scopeRows'
3+
4+/**
5+ * One row of "Items in this project" (mockup 9 and 10): switch, name + type + origin, status cell with its inline
6+ * action, Automatic / On demand for skills, and the inline resolve box. Editing only changes the config (autosaved);
7+ * files change on Apply.
8+ */
9+const props = defineProps<{ row: ScopeRow, root: string, home?: boolean }>()
10+const emit = defineEmits<{ toggle: [on: boolean], mode: [onDemand: boolean], review: [], import: [] }>()
11+
12+const open = ref(false)
13+const faded = computed(() => props.row.excluded || props.row.kind === 'removal')
14+const switchable = computed(() => props.row.kind === 'entry')
15+const switchDisabled = computed(() => props.row.excluded && !props.row.excludedExact)
16+const switchTitle = computed(() => {
17+ if (switchDisabled.value) return `Excluded by the pattern ${props.row.excludePattern}`
18+ if (props.row.excluded) return 'Include this item'
19+ return props.row.viaPreset ? 'Exclude from this project' : 'Remove from this project'
20+})
21+const mode = computed({
22+ get: () => (props.row.onDemand.on ? 'demand' : 'auto'),
23+ set: (v: 'auto' | 'demand') => emit('mode', v === 'demand')
24+})
25+const MODE_OPTIONS = computed(() => [
26+ { value: 'auto' as const, label: 'Automatic', disabled: props.row.onDemand.on && !props.row.onDemand.exact, title: 'The agent sees the skill and uses it when relevant' },
27+ { value: 'demand' as const, label: 'On demand', title: 'Installed but hidden from the agent: you invoke it with /name in Claude Code or $name in Codex' }
28+])
29+
30+function onAction(kind: RowAction) {
31+ if (kind === 'resolve') open.value = !open.value
32+ else if (kind === 'review') emit('review')
33+ else emit('import')
34+}
35+</script>
36+
37+<template>
38+ <div class="border-t border-sk-line2 first:border-t-0">
39+ <div class="grid items-center gap-[14px] px-4 py-[9px]" style="grid-template-columns: 30px minmax(0, 1fr) auto 176px">
40+ <UTooltip v-if="switchable" :text="switchTitle">
41+ <span class="inline-flex">
42+ <USwitch
43+ :model-value="!row.excluded"
44+ :disabled="switchDisabled"
45+ :aria-label="`${switchTitle}: ${row.name}`"
46+ @update:model-value="(v: boolean) => emit('toggle', v)"
47+ />
48+ </span>
49+ </UTooltip>
50+ <span v-else />
51+
52+ <div class="min-w-0" :class="faded && 'opacity-55'">
53+ <div class="flex items-baseline gap-2">
54+ <NuxtLink v-if="row.inCatalog" :to="`/catalog/${row.type}/${encodeURIComponent(row.name)}`" class="truncate font-medium text-sk-text hover:underline">{{ row.name }}</NuxtLink>
55+ <span v-else class="truncate font-medium">{{ row.name }}</span>
56+ <SkTypeTag :type="row.type" />
57+ </div>
58+ <div class="flex flex-wrap items-center gap-x-2 text-xs text-sk-muted">
59+ <span>{{ row.origin }}</span>
60+ <span v-if="row.kind === 'entry' && !row.inCatalog" class="font-mono text-[11px] text-sk-accent-text">not in catalog</span>
61+ </div>
62+ </div>
63+
64+ <div :class="faded && row.kind !== 'removal' && 'opacity-55'">
65+ <ScopeRowStatus :row="row" @action="onAction" />
66+ </div>
67+
68+ <div class="justify-self-end">
69+ <template v-if="row.type === 'skill' && row.kind === 'entry' && !row.excluded">
70+ <SkSeg v-model="mode" :options="MODE_OPTIONS" size="xs" aria-label="Skill mode" />
71+ <p v-if="row.onDemand.pattern" class="m-0 mt-0.5 text-right font-mono text-[11px] text-sk-faint">matches {{ row.onDemand.pattern }}</p>
72+ </template>
73+ </div>
74+ </div>
75+
76+ <div v-if="open && row.conflicts.length" class="px-4 pb-3.5 pl-[60px]">
77+ <ScopeResolveBox :conflicts="row.conflicts" :root="root" :home="home" />
78+ </div>
79+ </div>
80+</template>
new file mode 100644
@@ -0,0 +1,80 @@
1+<script setup lang="ts">
2+import type { RowAction, ScopeRow } from '../../utils/scopeRows'
3+
4+/**
5+ * One row of "Items in this project" (mockup 9 and 10): switch, name + type + origin, status cell with its inline
6+ * action, Automatic / On demand for skills, and the inline resolve box. Editing only changes the config (autosaved);
7+ * files change on Apply.
8+ */
9+const props = defineProps<{ row: ScopeRow, root: string, home?: boolean }>()
10+const emit = defineEmits<{ toggle: [on: boolean], mode: [onDemand: boolean], review: [], import: [] }>()
11+
12+const open = ref(false)
13+const faded = computed(() => props.row.excluded || props.row.kind === 'removal')
14+const switchable = computed(() => props.row.kind === 'entry')
15+const switchDisabled = computed(() => props.row.excluded && !props.row.excludedExact)
16+const switchTitle = computed(() => {
17+ if (switchDisabled.value) return `Excluded by the pattern ${props.row.excludePattern}`
18+ if (props.row.excluded) return 'Include this item'
19+ return props.row.viaPreset ? 'Exclude from this project' : 'Remove from this project'
20+})
21+const mode = computed({
22+ get: () => (props.row.onDemand.on ? 'demand' : 'auto'),
23+ set: (v: 'auto' | 'demand') => emit('mode', v === 'demand')
24+})
25+const MODE_OPTIONS = computed(() => [
26+ { value: 'auto' as const, label: 'Automatic', disabled: props.row.onDemand.on && !props.row.onDemand.exact, title: 'The agent sees the skill and uses it when relevant' },
27+ { value: 'demand' as const, label: 'On demand', title: 'Installed but hidden from the agent: you invoke it with /name in Claude Code or $name in Codex' }
28+])
29+
30+function onAction(kind: RowAction) {
31+ if (kind === 'resolve') open.value = !open.value
32+ else if (kind === 'review') emit('review')
33+ else emit('import')
34+}
35+</script>
36+
37+<template>
38+ <div class="border-t border-sk-line2 first:border-t-0">
39+ <div class="grid items-center gap-[14px] px-4 py-[9px]" style="grid-template-columns: 30px minmax(0, 1fr) auto 176px">
40+ <UTooltip v-if="switchable" :text="switchTitle">
41+ <span class="inline-flex">
42+ <USwitch
43+ :model-value="!row.excluded"
44+ :disabled="switchDisabled"
45+ :aria-label="`${switchTitle}: ${row.name}`"
46+ @update:model-value="(v: boolean) => emit('toggle', v)"
47+ />
48+ </span>
49+ </UTooltip>
50+ <span v-else />
51+
52+ <div class="min-w-0" :class="faded && 'opacity-55'">
53+ <div class="flex items-baseline gap-2">
54+ <NuxtLink v-if="row.inCatalog" :to="`/catalog/${row.type}/${encodeURIComponent(row.name)}`" class="truncate font-medium text-sk-text hover:underline">{{ row.name }}</NuxtLink>
55+ <span v-else class="truncate font-medium">{{ row.name }}</span>
56+ <SkTypeTag :type="row.type" />
57+ </div>
58+ <div class="flex flex-wrap items-center gap-x-2 text-xs text-sk-muted">
59+ <span>{{ row.origin }}</span>
60+ <span v-if="row.kind === 'entry' && !row.inCatalog" class="font-mono text-[11px] text-sk-accent-text">not in catalog</span>
61+ </div>
62+ </div>
63+
64+ <div :class="faded && row.kind !== 'removal' && 'opacity-55'">
65+ <ScopeRowStatus :row="row" @action="onAction" />
66+ </div>
67+
68+ <div class="justify-self-end">
69+ <template v-if="row.type === 'skill' && row.kind === 'entry' && !row.excluded">
70+ <SkSeg v-model="mode" :options="MODE_OPTIONS" size="xs" aria-label="Skill mode" />
71+ <p v-if="row.onDemand.pattern" class="m-0 mt-0.5 text-right font-mono text-[11px] text-sk-faint">matches {{ row.onDemand.pattern }}</p>
72+ </template>
73+ </div>
74+ </div>
75+
76+ <div v-if="open && row.conflicts.length" class="px-4 pb-3.5 pl-[60px]">
77+ <ScopeResolveBox :conflicts="row.conflicts" :root="root" :home="home" />
78+ </div>
79+ </div>
80+</template>
added packages/ui/app/components/scope/ScopeItemsSection.vue +140 -0
new file mode 100644
@@ -0,0 +1,140 @@
1+<script setup lang="ts">
2+import type { CatalogItemType } from '@skilly/core'
3+import type { HomeNames, ScopeRow } from '../../utils/scopeRows'
4+
5+/** "Items in this project": one row per item with switch, status, mode and inline resolution (mockup 9-10). */
6+const scope = useScopeContext()
7+const catalog = useCatalog()
8+const { home: homeInv, refresh: refreshHome } = useHomeInventory()
9+const home = scope.kind === 'home'
10+
11+onMounted(() => {
12+ if (!home && !homeInv.value) refreshHome()
13+})
14+
15+const homeNames = computed<HomeNames>(() => {
16+ const out: HomeNames = { skill: new Set(), mcp: new Set() }
17+ for (const i of homeInv.value?.items ?? []) {
18+ if (i.type === 'skill') out.skill.add(i.name)
19+ else if (i.type === 'mcp') out.mcp.add(i.name)
20+ }
21+ return out
22+})
23+
24+const rows = computed<ScopeRow[]>(() => {
25+ const config = scope.config.value
26+ if (!config) return []
27+ return buildRows({
28+ config,
29+ presets: catalog.presets.value,
30+ catalogItems: catalog.items.value,
31+ plan: scope.plan.value,
32+ inventory: scope.inventory.value,
33+ statuses: scope.statuses.value,
34+ homeNames: home ? undefined : homeNames.value,
35+ includeLocal: !home
36+ })
37+})
38+
39+const hint = computed(() => writtenToHint(scope.config.value?.targets ?? [], home))
40+
41+const patterns = computed(() => {
42+ const c = scope.config.value
43+ if (!c) return []
44+ const out: string[] = []
45+ for (const [t, l] of Object.entries(c.exclude)) for (const p of l ?? []) if (p.includes('*')) out.push(`exclude ${t}: ${p}`)
46+ for (const p of c.onDemand.skills) if (p.includes('*')) out.push(`on demand: ${p}`)
47+ return out
48+})
49+
50+function onToggle(row: ScopeRow, on: boolean) {
51+ scope.edit((c) => {
52+ const t = row.type
53+ if (row.excluded || row.viaPreset) {
54+ const list = toggleIn(c.exclude[t] ?? [], row.name, !on)
55+ if (list.length) c.exclude[t] = list
56+ else delete c.exclude[t]
57+ } else {
58+ const list = (c.add[t] ?? []).filter(x => x !== row.name)
59+ if (list.length) c.add[t] = list
60+ else delete c.add[t]
61+ }
62+ })
63+}
64+
65+function onMode(row: ScopeRow, onDemand: boolean) {
66+ scope.edit((c) => { c.onDemand.skills = toggleIn(c.onDemand.skills, row.name, onDemand) })
67+}
68+
69+function onImport(row: ScopeRow) {
70+ const items = (scope.inventory.value?.items ?? []).filter(i => i.type === row.type && i.name === row.name && !scope.statuses.value[i.id]?.managed)
71+ if (items.length) scope.askImport(items)
72+}
73+
74+/* ---------------------------------------------------------------- add items */
75+
76+const ADD_TYPES = computed<CatalogItemType[]>(() => (home ? ['skill', 'rule', 'agent'] : ['skill', 'rule', 'agent', 'mcp', 'hook']))
77+const pickerOpen = ref(false)
78+const pickerSel = ref<Partial<Record<CatalogItemType, string[]>>>({})
79+const pickable = computed(() => catalog.items.value.filter(i => ADD_TYPES.value.includes(i.type)))
80+const locked = computed(() => {
81+ const m: Record<string, string> = {}
82+ const c = scope.config.value
83+ if (!c) return m
84+ for (const e of resolveItems(catalog.presets.value, c.presets, {}).entries) {
85+ m[`${e.type}/${e.name}`] = `via ${e.via[0]}`
86+ }
87+ return m
88+})
89+
90+function openPicker() {
91+ pickerSel.value = structuredClone(toRaw(scope.config.value?.add ?? {}))
92+ pickerOpen.value = true
93+}
94+function confirmPicker() {
95+ scope.edit((c) => {
96+ for (const t of ADD_TYPES.value) {
97+ const l = pickerSel.value[t] ?? []
98+ if (l.length) c.add[t] = l
99+ else delete c.add[t]
100+ }
101+ })
102+ pickerOpen.value = false
103+}
104+</script>
105+
106+<template>
107+ <SkSection :title="home ? 'Items in Home' : 'Items in this project'">
108+ <template #actions>
109+ <span class="hidden text-pretty text-sk-muted sm:inline">{{ hint }}</span>
110+ <SkButton label="Add items" :disabled="!pickable.length" @click="openPicker" />
111+ </template>
112+
113+ <SkPanel>
114+ <ScopeItemRow
115+ v-for="r in rows"
116+ :key="r.key"
117+ :row="r"
118+ :root="scope.plan.value?.root ?? scope.rootPath.value"
119+ :home="home"
120+ @toggle="(on) => onToggle(r, on)"
121+ @mode="(d) => onMode(r, d)"
122+ @review="scope.openReview()"
123+ @import="onImport(r)"
124+ />
125+ <SkEmpty v-if="!rows.length">
126+ {{ catalog.exists.value ? 'Nothing enabled yet. Add a preset above, or add items one by one.' : 'No catalog yet. Create the catalog from the Catalog page, then enable presets and items here.' }}
127+ </SkEmpty>
128+ </SkPanel>
129+ <p v-if="patterns.length" class="m-0 font-mono text-[11px] text-sk-faint">Patterns kept as is: {{ patterns.join(' · ') }}</p>
130+ </SkSection>
131+
132+ <SkModal v-model:open="pickerOpen" :title="home ? 'Add items to Home' : 'Add items'" description="Pick items from the catalog to enable one by one." width="lg">
133+ <ItemPicker v-model="pickerSel" :items="pickable" :types="ADD_TYPES" :locked="locked" height="h-80" />
134+ <p v-if="home" class="m-0 mt-2.5 text-xs text-sk-muted">Home can receive skills, rules and subagents. Its MCP servers and hooks are read-only in v1.</p>
135+ <template #footer>
136+ <SkButton label="Cancel" @click="pickerOpen = false" />
137+ <SkButton variant="primary" label="Save selection" @click="confirmPicker" />
138+ </template>
139+ </SkModal>
140+</template>
new file mode 100644
@@ -0,0 +1,140 @@
1+<script setup lang="ts">
2+import type { CatalogItemType } from '@skilly/core'
3+import type { HomeNames, ScopeRow } from '../../utils/scopeRows'
4+
5+/** "Items in this project": one row per item with switch, status, mode and inline resolution (mockup 9-10). */
6+const scope = useScopeContext()
7+const catalog = useCatalog()
8+const { home: homeInv, refresh: refreshHome } = useHomeInventory()
9+const home = scope.kind === 'home'
10+
11+onMounted(() => {
12+ if (!home && !homeInv.value) refreshHome()
13+})
14+
15+const homeNames = computed<HomeNames>(() => {
16+ const out: HomeNames = { skill: new Set(), mcp: new Set() }
17+ for (const i of homeInv.value?.items ?? []) {
18+ if (i.type === 'skill') out.skill.add(i.name)
19+ else if (i.type === 'mcp') out.mcp.add(i.name)
20+ }
21+ return out
22+})
23+
24+const rows = computed<ScopeRow[]>(() => {
25+ const config = scope.config.value
26+ if (!config) return []
27+ return buildRows({
28+ config,
29+ presets: catalog.presets.value,
30+ catalogItems: catalog.items.value,
31+ plan: scope.plan.value,
32+ inventory: scope.inventory.value,
33+ statuses: scope.statuses.value,
34+ homeNames: home ? undefined : homeNames.value,
35+ includeLocal: !home
36+ })
37+})
38+
39+const hint = computed(() => writtenToHint(scope.config.value?.targets ?? [], home))
40+
41+const patterns = computed(() => {
42+ const c = scope.config.value
43+ if (!c) return []
44+ const out: string[] = []
45+ for (const [t, l] of Object.entries(c.exclude)) for (const p of l ?? []) if (p.includes('*')) out.push(`exclude ${t}: ${p}`)
46+ for (const p of c.onDemand.skills) if (p.includes('*')) out.push(`on demand: ${p}`)
47+ return out
48+})
49+
50+function onToggle(row: ScopeRow, on: boolean) {
51+ scope.edit((c) => {
52+ const t = row.type
53+ if (row.excluded || row.viaPreset) {
54+ const list = toggleIn(c.exclude[t] ?? [], row.name, !on)
55+ if (list.length) c.exclude[t] = list
56+ else delete c.exclude[t]
57+ } else {
58+ const list = (c.add[t] ?? []).filter(x => x !== row.name)
59+ if (list.length) c.add[t] = list
60+ else delete c.add[t]
61+ }
62+ })
63+}
64+
65+function onMode(row: ScopeRow, onDemand: boolean) {
66+ scope.edit((c) => { c.onDemand.skills = toggleIn(c.onDemand.skills, row.name, onDemand) })
67+}
68+
69+function onImport(row: ScopeRow) {
70+ const items = (scope.inventory.value?.items ?? []).filter(i => i.type === row.type && i.name === row.name && !scope.statuses.value[i.id]?.managed)
71+ if (items.length) scope.askImport(items)
72+}
73+
74+/* ---------------------------------------------------------------- add items */
75+
76+const ADD_TYPES = computed<CatalogItemType[]>(() => (home ? ['skill', 'rule', 'agent'] : ['skill', 'rule', 'agent', 'mcp', 'hook']))
77+const pickerOpen = ref(false)
78+const pickerSel = ref<Partial<Record<CatalogItemType, string[]>>>({})
79+const pickable = computed(() => catalog.items.value.filter(i => ADD_TYPES.value.includes(i.type)))
80+const locked = computed(() => {
81+ const m: Record<string, string> = {}
82+ const c = scope.config.value
83+ if (!c) return m
84+ for (const e of resolveItems(catalog.presets.value, c.presets, {}).entries) {
85+ m[`${e.type}/${e.name}`] = `via ${e.via[0]}`
86+ }
87+ return m
88+})
89+
90+function openPicker() {
91+ pickerSel.value = structuredClone(toRaw(scope.config.value?.add ?? {}))
92+ pickerOpen.value = true
93+}
94+function confirmPicker() {
95+ scope.edit((c) => {
96+ for (const t of ADD_TYPES.value) {
97+ const l = pickerSel.value[t] ?? []
98+ if (l.length) c.add[t] = l
99+ else delete c.add[t]
100+ }
101+ })
102+ pickerOpen.value = false
103+}
104+</script>
105+
106+<template>
107+ <SkSection :title="home ? 'Items in Home' : 'Items in this project'">
108+ <template #actions>
109+ <span class="hidden text-pretty text-sk-muted sm:inline">{{ hint }}</span>
110+ <SkButton label="Add items" :disabled="!pickable.length" @click="openPicker" />
111+ </template>
112+
113+ <SkPanel>
114+ <ScopeItemRow
115+ v-for="r in rows"
116+ :key="r.key"
117+ :row="r"
118+ :root="scope.plan.value?.root ?? scope.rootPath.value"
119+ :home="home"
120+ @toggle="(on) => onToggle(r, on)"
121+ @mode="(d) => onMode(r, d)"
122+ @review="scope.openReview()"
123+ @import="onImport(r)"
124+ />
125+ <SkEmpty v-if="!rows.length">
126+ {{ catalog.exists.value ? 'Nothing enabled yet. Add a preset above, or add items one by one.' : 'No catalog yet. Create the catalog from the Catalog page, then enable presets and items here.' }}
127+ </SkEmpty>
128+ </SkPanel>
129+ <p v-if="patterns.length" class="m-0 font-mono text-[11px] text-sk-faint">Patterns kept as is: {{ patterns.join(' · ') }}</p>
130+ </SkSection>
131+
132+ <SkModal v-model:open="pickerOpen" :title="home ? 'Add items to Home' : 'Add items'" description="Pick items from the catalog to enable one by one." width="lg">
133+ <ItemPicker v-model="pickerSel" :items="pickable" :types="ADD_TYPES" :locked="locked" height="h-80" />
134+ <p v-if="home" class="m-0 mt-2.5 text-xs text-sk-muted">Home can receive skills, rules and subagents. Its MCP servers and hooks are read-only in v1.</p>
135+ <template #footer>
136+ <SkButton label="Cancel" @click="pickerOpen = false" />
137+ <SkButton variant="primary" label="Save selection" @click="confirmPicker" />
138+ </template>
139+ </SkModal>
140+</template>
added packages/ui/app/components/scope/ScopeManageCard.vue +59 -0
new file mode 100644
@@ -0,0 +1,59 @@
1+<script setup lang="ts">
2+import type { AgentId } from '@skilly/core'
3+
4+/** Empty state of a scope without skilly.json / home.json: choose the target agents and start managing. */
5+const scope = useScopeContext()
6+const home = scope.kind === 'home'
7+const AGENT_LIST: AgentId[] = ['claude', 'codex']
8+
9+const targets = ref<AgentId[]>([])
10+watch(scope.inventory, (inv) => {
11+ if (!targets.value.length) targets.value = inv?.agentsDetected.length ? [...inv.agentsDetected] : [...AGENT_LIST]
12+}, { immediate: true })
13+
14+function toggle(a: AgentId, on: boolean | 'indeterminate') {
15+ targets.value = on === true ? [...new Set([...targets.value, a])] : targets.value.filter(x => x !== a)
16+}
17+
18+function start() {
19+ scope.saveConfig(emptyConfig(targets.value), true)
20+}
21+</script>
22+
23+<template>
24+ <SkPanel padded class="flex flex-col gap-5">
25+ <div class="flex flex-col gap-1">
26+ <h2 class="m-0 text-[15px] font-semibold">{{ home ? 'Manage Home with skilly' : 'Manage this project with skilly' }}</h2>
27+ <p class="m-0 max-w-2xl text-pretty text-sk-muted">
28+ Enable presets and catalog items, switch skills to on demand and keep the files in sync.
29+ {{ home
30+ ? 'Settings are stored in ~/.skilly/home.json. Nothing is written into your agent folders until you apply.'
31+ : 'A skilly.json file is created at the project root: commit it with the project. Nothing else is written until you apply.' }}
32+ </p>
33+ </div>
34+
35+ <div class="flex flex-col gap-2.5">
36+ <SkSectionLabel>Write files for</SkSectionLabel>
37+ <div class="grid gap-2.5 sm:grid-cols-2">
38+ <label
39+ v-for="a in AGENT_LIST"
40+ :key="a"
41+ class="flex cursor-pointer items-start gap-3 rounded-[10px] border p-3.5 transition-colors"
42+ :class="targets.includes(a) ? 'border-sk-ink' : 'border-sk-line hover:border-sk-faint'"
43+ >
44+ <UCheckbox :model-value="targets.includes(a)" :aria-label="AGENT_LABELS[a]" @update:model-value="(v) => toggle(a, v)" />
45+ <span class="flex flex-col gap-0.5">
46+ <span class="font-medium">{{ AGENT_LABELS[a] }}</span>
47+ <span class="font-mono text-[11px] text-sk-faint">{{ a === 'claude' ? (home ? '~/.claude' : '.claude/') : (home ? '~/.agents · ~/.codex' : '.agents/ · .codex/') }}</span>
48+ <span v-if="scope.inventory.value?.agentsDetected.includes(a)" class="text-xs text-sk-muted">Detected here</span>
49+ </span>
50+ </label>
51+ </div>
52+ </div>
53+
54+ <div class="flex items-center gap-3">
55+ <SkButton variant="primary" label="Start managing" :loading="scope.saving.value" :disabled="!targets.length" @click="start" />
56+ <span v-if="!targets.length" class="text-xs text-sk-accent-text">Pick at least one agent.</span>
57+ </div>
58+ </SkPanel>
59+</template>
new file mode 100644
@@ -0,0 +1,59 @@
1+<script setup lang="ts">
2+import type { AgentId } from '@skilly/core'
3+
4+/** Empty state of a scope without skilly.json / home.json: choose the target agents and start managing. */
5+const scope = useScopeContext()
6+const home = scope.kind === 'home'
7+const AGENT_LIST: AgentId[] = ['claude', 'codex']
8+
9+const targets = ref<AgentId[]>([])
10+watch(scope.inventory, (inv) => {
11+ if (!targets.value.length) targets.value = inv?.agentsDetected.length ? [...inv.agentsDetected] : [...AGENT_LIST]
12+}, { immediate: true })
13+
14+function toggle(a: AgentId, on: boolean | 'indeterminate') {
15+ targets.value = on === true ? [...new Set([...targets.value, a])] : targets.value.filter(x => x !== a)
16+}
17+
18+function start() {
19+ scope.saveConfig(emptyConfig(targets.value), true)
20+}
21+</script>
22+
23+<template>
24+ <SkPanel padded class="flex flex-col gap-5">
25+ <div class="flex flex-col gap-1">
26+ <h2 class="m-0 text-[15px] font-semibold">{{ home ? 'Manage Home with skilly' : 'Manage this project with skilly' }}</h2>
27+ <p class="m-0 max-w-2xl text-pretty text-sk-muted">
28+ Enable presets and catalog items, switch skills to on demand and keep the files in sync.
29+ {{ home
30+ ? 'Settings are stored in ~/.skilly/home.json. Nothing is written into your agent folders until you apply.'
31+ : 'A skilly.json file is created at the project root: commit it with the project. Nothing else is written until you apply.' }}
32+ </p>
33+ </div>
34+
35+ <div class="flex flex-col gap-2.5">
36+ <SkSectionLabel>Write files for</SkSectionLabel>
37+ <div class="grid gap-2.5 sm:grid-cols-2">
38+ <label
39+ v-for="a in AGENT_LIST"
40+ :key="a"
41+ class="flex cursor-pointer items-start gap-3 rounded-[10px] border p-3.5 transition-colors"
42+ :class="targets.includes(a) ? 'border-sk-ink' : 'border-sk-line hover:border-sk-faint'"
43+ >
44+ <UCheckbox :model-value="targets.includes(a)" :aria-label="AGENT_LABELS[a]" @update:model-value="(v) => toggle(a, v)" />
45+ <span class="flex flex-col gap-0.5">
46+ <span class="font-medium">{{ AGENT_LABELS[a] }}</span>
47+ <span class="font-mono text-[11px] text-sk-faint">{{ a === 'claude' ? (home ? '~/.claude' : '.claude/') : (home ? '~/.agents · ~/.codex' : '.agents/ · .codex/') }}</span>
48+ <span v-if="scope.inventory.value?.agentsDetected.includes(a)" class="text-xs text-sk-muted">Detected here</span>
49+ </span>
50+ </label>
51+ </div>
52+ </div>
53+
54+ <div class="flex items-center gap-3">
55+ <SkButton variant="primary" label="Start managing" :loading="scope.saving.value" :disabled="!targets.length" @click="start" />
56+ <span v-if="!targets.length" class="text-xs text-sk-accent-text">Pick at least one agent.</span>
57+ </div>
58+ </SkPanel>
59+</template>
added packages/ui/app/components/scope/ScopePage.vue +200 -0
new file mode 100644
@@ -0,0 +1,200 @@
1+<script setup lang="ts">
2+/**
3+ * Home and project page (design/BRIEF.md 2.2, 2.4, 4.1): header, tab segment, tab content, the sticky "pending
4+ * changes" bar and the overlays (item drawer, review drawer, import and remove-from-global modals).
5+ * State lives in `createScope` and is shared with the scope components through `useScopeContext()`.
6+ */
7+const props = defineProps<{ kind: 'home' | 'project', projectId?: string }>()
8+
9+const scope = createScope(props.kind, () => props.projectId)
10+provideScope(scope)
11+const {
12+ config, inventory, loading, error, saving, plan, statuses, folderMissing, rootPath, title, scopeId, pending, openConflicts,
13+ itemOpen, reviewOpen, importOpen, importItems, moveOpen, moveItems
14+} = scope
15+
16+const { remove } = useProjects()
17+const catalog = useCatalog()
18+const route = useRoute()
19+const router = useRouter()
20+const toast = useToast()
21+const home = props.kind === 'home'
22+
23+type Tab = 'inventory' | 'config' | 'files' | 'instructions'
24+const TABS: { value: Tab, label: string }[] = home
25+ ? [{ value: 'inventory', label: 'Inventory' }, { value: 'config', label: 'Configuration' }]
26+ : [{ value: 'config', label: 'Configuration' }, { value: 'files', label: 'Files' }, { value: 'instructions', label: 'Instructions' }]
27+const tab = computed<Tab>({
28+ get: () => {
29+ const q = String(route.query.tab ?? '')
30+ return (TABS.find(t => t.value === q)?.value ?? TABS[0]!.value)
31+ },
32+ set: (v) => { router.replace({ query: { ...route.query, tab: v === TABS[0]!.value ? undefined : v } }) }
33+})
34+
35+onMounted(async () => {
36+ if (!catalog.data.value) catalog.refresh()
37+ await scope.load()
38+})
39+
40+/* ---------------------------------------------------------------- header */
41+
42+const removeOpen = ref(false)
43+const removing = ref(false)
44+const scanErrorsOpen = ref(false)
45+
46+async function copyPath() {
47+ try {
48+ await navigator.clipboard.writeText(rootPath.value)
49+ toast.add({ title: 'Path copied', icon: 'i-lucide-check', color: 'success' })
50+ } catch {
51+ toast.add({ title: 'Could not copy', color: 'error' })
52+ }
53+}
54+
55+async function confirmRemove() {
56+ if (!props.projectId) return
57+ removing.value = true
58+ try {
59+ await remove(props.projectId)
60+ removeOpen.value = false
61+ toast.add({ title: 'Project removed from skilly', description: 'No file was deleted.', icon: 'i-lucide-check', color: 'success' })
62+ await router.push('/projects')
63+ } catch {
64+ // The error toast comes from useApi.
65+ } finally {
66+ removing.value = false
67+ }
68+}
69+
70+const menu = computed(() => [[
71+ { label: 'Rescan', icon: 'i-lucide-refresh-cw', onSelect: () => scope.rescan() },
72+ { label: 'Copy path', icon: 'i-lucide-copy', onSelect: copyPath },
73+ ...(home ? [] : [{ label: 'Remove from skilly', icon: 'i-lucide-trash-2', color: 'error' as const, onSelect: () => { removeOpen.value = true } }])
74+]])
75+
76+const agentsText = computed(() => {
77+ const list = config.value?.targets ?? inventory.value?.agentsDetected ?? []
78+ return list.length ? list.map(a => AGENT_LABELS[a]).join(', ') : 'No agent detected'
79+})
80+
81+/* ---------------------------------------------------------------- pending bar */
82+
83+const barVisible = computed(() => !!config.value && (pending.value.length > 0 || (plan.value?.missing.length ?? 0) > 0))
84+
85+const homeInstructionCount = computed(() => (inventory.value?.items ?? []).filter(i => i.type === 'instructions').length)
86+</script>
87+
88+<template>
89+ <SkPage>
90+ <SkPageHeader :title="home ? 'Home · user-level config' : title" :subtitle="home ? 'Everything here is visible in every project.' : undefined" :aside="home">
91+ <template v-if="home" #lead><SkAvatar /></template>
92+ <template v-else #breadcrumb><SkBreadcrumb :items="[{ label: 'Projects', to: '/projects' }]" /></template>
93+ <template v-if="!home" #subtitle>
94+ <span class="flex flex-wrap items-center gap-x-2 gap-y-1 font-mono text-xs text-sk-faint">
95+ <span class="max-w-[360px] truncate" :title="rootPath">{{ rootPath }}</span>
96+ <template v-if="inventory">
97+ <span aria-hidden="true">·</span>
98+ <ScopeTargets v-if="config" />
99+ <span v-else>{{ agentsText }}</span>
100+ </template>
101+ </span>
102+ </template>
103+ <template #actions>
104+ <SkButton v-if="home" variant="ghost" icon="i-lucide-refresh-cw" label="Rescan" :loading="loading && !!inventory" @click="scope.rescan()" />
105+ <SkSeg v-if="inventory || !loading" v-model="tab" :options="TABS" aria-label="Page section" />
106+ <UDropdownMenu v-if="!home" :items="menu" :content="{ align: 'end' }">
107+ <SkButton variant="ghost" icon="i-lucide-ellipsis" aria-label="More actions" />
108+ </UDropdownMenu>
109+ </template>
110+ </SkPageHeader>
111+
112+ <!-- Loading -->
113+ <div v-if="loading && !inventory" class="flex flex-col gap-3">
114+ <USkeleton class="h-24 w-full rounded-[20px]" />
115+ <USkeleton class="h-64 w-full rounded-[20px]" />
116+ </div>
117+
118+ <!-- Error -->
119+ <template v-else-if="error && !inventory">
120+ <SkNote v-if="folderMissing" tone="accent">
121+ <p class="m-0 font-semibold">This folder no longer exists on disk</p>
122+ <p class="m-0 mt-1">{{ rootPath }} was moved or deleted. Remove the project from skilly, or retry once the folder is back.</p>
123+ <template #aside>
124+ <div class="flex gap-2">
125+ <SkButton size="sm" label="Retry" @click="scope.load()" />
126+ <SkButton size="sm" variant="danger" label="Remove from skilly" @click="removeOpen = true" />
127+ </div>
128+ </template>
129+ </SkNote>
130+ <SkNote v-else tone="accent">
131+ <p class="m-0 font-semibold">Could not load this {{ home ? 'Home' : 'project' }}</p>
132+ <p class="m-0 mt-1">{{ error }}</p>
133+ <template #aside><SkButton size="sm" label="Retry" @click="scope.load()" /></template>
134+ </SkNote>
135+ </template>
136+
137+ <template v-else-if="inventory">
138+ <SkNote v-if="folderMissing" tone="accent">
139+ <p class="m-0 font-semibold">This folder no longer exists on disk</p>
140+ <p class="m-0 mt-1">The inventory below may be empty or outdated. You can remove the project from skilly.</p>
141+ </SkNote>
142+
143+ <SkNote v-if="inventory.errors.length && tab !== 'inventory'" tone="accent">
144+ <button type="button" class="cursor-pointer border-0 bg-transparent p-0 text-left font-semibold text-inherit" @click="scanErrorsOpen = !scanErrorsOpen">
145+ {{ inventory.errors.length }} item{{ inventory.errors.length > 1 ? 's' : '' }} could not be read
146+ <span class="font-normal opacity-80">· {{ scanErrorsOpen ? 'hide' : 'show' }} details</span>
147+ </button>
148+ <ScopeScanErrors v-if="scanErrorsOpen" :errors="inventory.errors" :root="rootPath" />
149+ </SkNote>
150+
151+ <!-- Home -->
152+ <template v-if="home">
153+ <HomeInventory v-if="tab === 'inventory'" />
154+ <template v-else>
155+ <SkNote tone="accent">This writes into your Home folders (~/.claude, ~/.agents, ~/.codex). Every project sees what is enabled here.</SkNote>
156+ <ScopeManageCard v-if="!config" />
157+ <ScopeConfigTab v-else />
158+ </template>
159+ </template>
160+
161+ <!-- Project -->
162+ <template v-else>
163+ <template v-if="tab === 'config'">
164+ <ScopeManageCard v-if="!config" />
165+ <ScopeConfigTab v-else />
166+ </template>
167+ <ScopeFilesTab v-else-if="tab === 'files'" />
168+ <ScopeInstructionsTab v-else />
169+ </template>
170+ </template>
171+
172+ <SkActionBar
173+ v-if="barVisible"
174+ :changes="pending.length"
175+ :conflicts="openConflicts.length"
176+ :saved="!saving"
177+ :busy="saving"
178+ @review="scope.openReview()"
179+ >
180+ <span v-if="!pending.length && plan?.missing.length" class="text-sk-muted">{{ plan.missing.length }} item{{ plan.missing.length > 1 ? 's' : '' }} not found in the catalog</span>
181+ <span v-if="saving" class="font-mono text-[11px] text-sk-faint">Saving…</span>
182+ </SkActionBar>
183+ </SkPage>
184+
185+ <ScopeItemDrawer v-model:open="itemOpen" />
186+ <ScopeReviewDrawer v-if="plan" v-model:open="reviewOpen" :scope-id="scopeId" :plan="plan" :home="home" @applied="scope.adoptState" />
187+ <AddToCatalogModal v-model:open="importOpen" :scope="scopeId" :items="importItems" :statuses="statuses" @done="scope.afterCatalogChange" />
188+ <RemoveFromGlobalModal v-model:open="moveOpen" :items="moveItems" :statuses="statuses" @done="scope.afterCatalogChange" />
189+
190+ <SkModal v-model:open="removeOpen" title="Remove project from skilly" description="This only forgets the project in skilly." width="sm">
191+ <p class="m-0">
192+ <strong>{{ title }}</strong> will disappear from the project list. <strong>No file is deleted</strong>:
193+ the folder and its agent configuration stay untouched. You can add it again at any time.
194+ </p>
195+ <template #footer>
196+ <SkButton label="Cancel" @click="removeOpen = false" />
197+ <SkButton variant="primary" label="Remove from skilly" :loading="removing" @click="confirmRemove" />
198+ </template>
199+ </SkModal>
200+</template>
new file mode 100644
@@ -0,0 +1,200 @@
1+<script setup lang="ts">
2+/**
3+ * Home and project page (design/BRIEF.md 2.2, 2.4, 4.1): header, tab segment, tab content, the sticky "pending
4+ * changes" bar and the overlays (item drawer, review drawer, import and remove-from-global modals).
5+ * State lives in `createScope` and is shared with the scope components through `useScopeContext()`.
6+ */
7+const props = defineProps<{ kind: 'home' | 'project', projectId?: string }>()
8+
9+const scope = createScope(props.kind, () => props.projectId)
10+provideScope(scope)
11+const {
12+ config, inventory, loading, error, saving, plan, statuses, folderMissing, rootPath, title, scopeId, pending, openConflicts,
13+ itemOpen, reviewOpen, importOpen, importItems, moveOpen, moveItems
14+} = scope
15+
16+const { remove } = useProjects()
17+const catalog = useCatalog()
18+const route = useRoute()
19+const router = useRouter()
20+const toast = useToast()
21+const home = props.kind === 'home'
22+
23+type Tab = 'inventory' | 'config' | 'files' | 'instructions'
24+const TABS: { value: Tab, label: string }[] = home
25+ ? [{ value: 'inventory', label: 'Inventory' }, { value: 'config', label: 'Configuration' }]
26+ : [{ value: 'config', label: 'Configuration' }, { value: 'files', label: 'Files' }, { value: 'instructions', label: 'Instructions' }]
27+const tab = computed<Tab>({
28+ get: () => {
29+ const q = String(route.query.tab ?? '')
30+ return (TABS.find(t => t.value === q)?.value ?? TABS[0]!.value)
31+ },
32+ set: (v) => { router.replace({ query: { ...route.query, tab: v === TABS[0]!.value ? undefined : v } }) }
33+})
34+
35+onMounted(async () => {
36+ if (!catalog.data.value) catalog.refresh()
37+ await scope.load()
38+})
39+
40+/* ---------------------------------------------------------------- header */
41+
42+const removeOpen = ref(false)
43+const removing = ref(false)
44+const scanErrorsOpen = ref(false)
45+
46+async function copyPath() {
47+ try {
48+ await navigator.clipboard.writeText(rootPath.value)
49+ toast.add({ title: 'Path copied', icon: 'i-lucide-check', color: 'success' })
50+ } catch {
51+ toast.add({ title: 'Could not copy', color: 'error' })
52+ }
53+}
54+
55+async function confirmRemove() {
56+ if (!props.projectId) return
57+ removing.value = true
58+ try {
59+ await remove(props.projectId)
60+ removeOpen.value = false
61+ toast.add({ title: 'Project removed from skilly', description: 'No file was deleted.', icon: 'i-lucide-check', color: 'success' })
62+ await router.push('/projects')
63+ } catch {
64+ // The error toast comes from useApi.
65+ } finally {
66+ removing.value = false
67+ }
68+}
69+
70+const menu = computed(() => [[
71+ { label: 'Rescan', icon: 'i-lucide-refresh-cw', onSelect: () => scope.rescan() },
72+ { label: 'Copy path', icon: 'i-lucide-copy', onSelect: copyPath },
73+ ...(home ? [] : [{ label: 'Remove from skilly', icon: 'i-lucide-trash-2', color: 'error' as const, onSelect: () => { removeOpen.value = true } }])
74+]])
75+
76+const agentsText = computed(() => {
77+ const list = config.value?.targets ?? inventory.value?.agentsDetected ?? []
78+ return list.length ? list.map(a => AGENT_LABELS[a]).join(', ') : 'No agent detected'
79+})
80+
81+/* ---------------------------------------------------------------- pending bar */
82+
83+const barVisible = computed(() => !!config.value && (pending.value.length > 0 || (plan.value?.missing.length ?? 0) > 0))
84+
85+const homeInstructionCount = computed(() => (inventory.value?.items ?? []).filter(i => i.type === 'instructions').length)
86+</script>
87+
88+<template>
89+ <SkPage>
90+ <SkPageHeader :title="home ? 'Home · user-level config' : title" :subtitle="home ? 'Everything here is visible in every project.' : undefined" :aside="home">
91+ <template v-if="home" #lead><SkAvatar /></template>
92+ <template v-else #breadcrumb><SkBreadcrumb :items="[{ label: 'Projects', to: '/projects' }]" /></template>
93+ <template v-if="!home" #subtitle>
94+ <span class="flex flex-wrap items-center gap-x-2 gap-y-1 font-mono text-xs text-sk-faint">
95+ <span class="max-w-[360px] truncate" :title="rootPath">{{ rootPath }}</span>
96+ <template v-if="inventory">
97+ <span aria-hidden="true">·</span>
98+ <ScopeTargets v-if="config" />
99+ <span v-else>{{ agentsText }}</span>
100+ </template>
101+ </span>
102+ </template>
103+ <template #actions>
104+ <SkButton v-if="home" variant="ghost" icon="i-lucide-refresh-cw" label="Rescan" :loading="loading && !!inventory" @click="scope.rescan()" />
105+ <SkSeg v-if="inventory || !loading" v-model="tab" :options="TABS" aria-label="Page section" />
106+ <UDropdownMenu v-if="!home" :items="menu" :content="{ align: 'end' }">
107+ <SkButton variant="ghost" icon="i-lucide-ellipsis" aria-label="More actions" />
108+ </UDropdownMenu>
109+ </template>
110+ </SkPageHeader>
111+
112+ <!-- Loading -->
113+ <div v-if="loading && !inventory" class="flex flex-col gap-3">
114+ <USkeleton class="h-24 w-full rounded-[20px]" />
115+ <USkeleton class="h-64 w-full rounded-[20px]" />
116+ </div>
117+
118+ <!-- Error -->
119+ <template v-else-if="error && !inventory">
120+ <SkNote v-if="folderMissing" tone="accent">
121+ <p class="m-0 font-semibold">This folder no longer exists on disk</p>
122+ <p class="m-0 mt-1">{{ rootPath }} was moved or deleted. Remove the project from skilly, or retry once the folder is back.</p>
123+ <template #aside>
124+ <div class="flex gap-2">
125+ <SkButton size="sm" label="Retry" @click="scope.load()" />
126+ <SkButton size="sm" variant="danger" label="Remove from skilly" @click="removeOpen = true" />
127+ </div>
128+ </template>
129+ </SkNote>
130+ <SkNote v-else tone="accent">
131+ <p class="m-0 font-semibold">Could not load this {{ home ? 'Home' : 'project' }}</p>
132+ <p class="m-0 mt-1">{{ error }}</p>
133+ <template #aside><SkButton size="sm" label="Retry" @click="scope.load()" /></template>
134+ </SkNote>
135+ </template>
136+
137+ <template v-else-if="inventory">
138+ <SkNote v-if="folderMissing" tone="accent">
139+ <p class="m-0 font-semibold">This folder no longer exists on disk</p>
140+ <p class="m-0 mt-1">The inventory below may be empty or outdated. You can remove the project from skilly.</p>
141+ </SkNote>
142+
143+ <SkNote v-if="inventory.errors.length && tab !== 'inventory'" tone="accent">
144+ <button type="button" class="cursor-pointer border-0 bg-transparent p-0 text-left font-semibold text-inherit" @click="scanErrorsOpen = !scanErrorsOpen">
145+ {{ inventory.errors.length }} item{{ inventory.errors.length > 1 ? 's' : '' }} could not be read
146+ <span class="font-normal opacity-80">· {{ scanErrorsOpen ? 'hide' : 'show' }} details</span>
147+ </button>
148+ <ScopeScanErrors v-if="scanErrorsOpen" :errors="inventory.errors" :root="rootPath" />
149+ </SkNote>
150+
151+ <!-- Home -->
152+ <template v-if="home">
153+ <HomeInventory v-if="tab === 'inventory'" />
154+ <template v-else>
155+ <SkNote tone="accent">This writes into your Home folders (~/.claude, ~/.agents, ~/.codex). Every project sees what is enabled here.</SkNote>
156+ <ScopeManageCard v-if="!config" />
157+ <ScopeConfigTab v-else />
158+ </template>
159+ </template>
160+
161+ <!-- Project -->
162+ <template v-else>
163+ <template v-if="tab === 'config'">
164+ <ScopeManageCard v-if="!config" />
165+ <ScopeConfigTab v-else />
166+ </template>
167+ <ScopeFilesTab v-else-if="tab === 'files'" />
168+ <ScopeInstructionsTab v-else />
169+ </template>
170+ </template>
171+
172+ <SkActionBar
173+ v-if="barVisible"
174+ :changes="pending.length"
175+ :conflicts="openConflicts.length"
176+ :saved="!saving"
177+ :busy="saving"
178+ @review="scope.openReview()"
179+ >
180+ <span v-if="!pending.length && plan?.missing.length" class="text-sk-muted">{{ plan.missing.length }} item{{ plan.missing.length > 1 ? 's' : '' }} not found in the catalog</span>
181+ <span v-if="saving" class="font-mono text-[11px] text-sk-faint">Saving…</span>
182+ </SkActionBar>
183+ </SkPage>
184+
185+ <ScopeItemDrawer v-model:open="itemOpen" />
186+ <ScopeReviewDrawer v-if="plan" v-model:open="reviewOpen" :scope-id="scopeId" :plan="plan" :home="home" @applied="scope.adoptState" />
187+ <AddToCatalogModal v-model:open="importOpen" :scope="scopeId" :items="importItems" :statuses="statuses" @done="scope.afterCatalogChange" />
188+ <RemoveFromGlobalModal v-model:open="moveOpen" :items="moveItems" :statuses="statuses" @done="scope.afterCatalogChange" />
189+
190+ <SkModal v-model:open="removeOpen" title="Remove project from skilly" description="This only forgets the project in skilly." width="sm">
191+ <p class="m-0">
192+ <strong>{{ title }}</strong> will disappear from the project list. <strong>No file is deleted</strong>:
193+ the folder and its agent configuration stay untouched. You can add it again at any time.
194+ </p>
195+ <template #footer>
196+ <SkButton label="Cancel" @click="removeOpen = false" />
197+ <SkButton variant="primary" label="Remove from skilly" :loading="removing" @click="confirmRemove" />
198+ </template>
199+ </SkModal>
200+</template>
added packages/ui/app/components/scope/ScopePresetsSection.vue +62 -0
new file mode 100644
@@ -0,0 +1,62 @@
1+<script setup lang="ts">
2+/** "Presets" section: one row per enabled preset (name, size, exclusions) and the Add presets modal. */
3+const scope = useScopeContext()
4+const catalog = useCatalog()
5+const addOpen = ref(false)
6+
7+const rows = computed(() => {
8+ const config = scope.config.value
9+ if (!config) return []
10+ return config.presets.map((name) => {
11+ const preset = catalog.presets.value.find(p => p.name === name)
12+ if (!preset) return { name, known: false, size: '', excluded: [] as string[] }
13+ const own = CAT_TYPES.reduce((n, t) => n + (preset.items[t]?.length ?? 0), 0)
14+ const size = `${own} item${own === 1 ? '' : 's'}${preset.includes.length ? ` + ${preset.includes.join(', ')}` : ''}`
15+ const excluded = resolveItems(catalog.presets.value, [name]).entries
16+ .filter(e => matchIn(config.exclude[e.type], e.name))
17+ .map(e => e.name)
18+ return { name, known: true, size, excluded }
19+ })
20+})
21+
22+function removePreset(name: string) {
23+ scope.edit((c) => { c.presets = c.presets.filter(p => p !== name) })
24+}
25+function addPresets(names: string[]) {
26+ scope.edit((c) => { c.presets = [...new Set([...c.presets, ...names])] })
27+ addOpen.value = false
28+}
29+</script>
30+
31+<template>
32+ <SkSection title="Presets">
33+ <template #actions>
34+ <SkButton label="Add preset" @click="addOpen = true" />
35+ </template>
36+ <SkPanel>
37+ <SkRow v-for="r in rows" :key="r.name" cols="minmax(0,1fr) minmax(0,1fr) auto" class="py-3">
38+ <div class="min-w-0">
39+ <div class="truncate font-medium">{{ r.name }}</div>
40+ <div v-if="r.known" class="text-xs text-sk-muted">{{ r.size }}</div>
41+ <SkStatus v-else tone="accent" label="Not found in catalog" small class="text-xs text-sk-muted" />
42+ </div>
43+ <span class="truncate text-center text-xs text-sk-muted" :title="r.excluded.join(', ')">
44+ {{ r.known ? (r.excluded.length ? `Excluded: ${r.excluded.join(', ')}` : 'No exclusions') : '' }}
45+ </span>
46+ <SkButton variant="ghost" size="sm" label="Remove" :aria-label="`Remove preset ${r.name}`" @click="removePreset(r.name)" />
47+ </SkRow>
48+ <SkEmpty v-if="!rows.length">
49+ {{ catalog.exists.value || catalog.presets.value.length ? 'No preset enabled. Add one, or enable items one by one below.' : 'No catalog yet. Create the catalog, then add presets or items.' }}
50+ </SkEmpty>
51+ </SkPanel>
52+ </SkSection>
53+
54+ <ScopeAddPresetsModal
55+ v-model:open="addOpen"
56+ :presets="catalog.presets.value"
57+ :enabled="scope.config.value?.presets ?? []"
58+ :scope-name="scope.title.value"
59+ :catalog-exists="catalog.exists.value"
60+ @add="addPresets"
61+ />
62+</template>
new file mode 100644
@@ -0,0 +1,62 @@
1+<script setup lang="ts">
2+/** "Presets" section: one row per enabled preset (name, size, exclusions) and the Add presets modal. */
3+const scope = useScopeContext()
4+const catalog = useCatalog()
5+const addOpen = ref(false)
6+
7+const rows = computed(() => {
8+ const config = scope.config.value
9+ if (!config) return []
10+ return config.presets.map((name) => {
11+ const preset = catalog.presets.value.find(p => p.name === name)
12+ if (!preset) return { name, known: false, size: '', excluded: [] as string[] }
13+ const own = CAT_TYPES.reduce((n, t) => n + (preset.items[t]?.length ?? 0), 0)
14+ const size = `${own} item${own === 1 ? '' : 's'}${preset.includes.length ? ` + ${preset.includes.join(', ')}` : ''}`
15+ const excluded = resolveItems(catalog.presets.value, [name]).entries
16+ .filter(e => matchIn(config.exclude[e.type], e.name))
17+ .map(e => e.name)
18+ return { name, known: true, size, excluded }
19+ })
20+})
21+
22+function removePreset(name: string) {
23+ scope.edit((c) => { c.presets = c.presets.filter(p => p !== name) })
24+}
25+function addPresets(names: string[]) {
26+ scope.edit((c) => { c.presets = [...new Set([...c.presets, ...names])] })
27+ addOpen.value = false
28+}
29+</script>
30+
31+<template>
32+ <SkSection title="Presets">
33+ <template #actions>
34+ <SkButton label="Add preset" @click="addOpen = true" />
35+ </template>
36+ <SkPanel>
37+ <SkRow v-for="r in rows" :key="r.name" cols="minmax(0,1fr) minmax(0,1fr) auto" class="py-3">
38+ <div class="min-w-0">
39+ <div class="truncate font-medium">{{ r.name }}</div>
40+ <div v-if="r.known" class="text-xs text-sk-muted">{{ r.size }}</div>
41+ <SkStatus v-else tone="accent" label="Not found in catalog" small class="text-xs text-sk-muted" />
42+ </div>
43+ <span class="truncate text-center text-xs text-sk-muted" :title="r.excluded.join(', ')">
44+ {{ r.known ? (r.excluded.length ? `Excluded: ${r.excluded.join(', ')}` : 'No exclusions') : '' }}
45+ </span>
46+ <SkButton variant="ghost" size="sm" label="Remove" :aria-label="`Remove preset ${r.name}`" @click="removePreset(r.name)" />
47+ </SkRow>
48+ <SkEmpty v-if="!rows.length">
49+ {{ catalog.exists.value || catalog.presets.value.length ? 'No preset enabled. Add one, or enable items one by one below.' : 'No catalog yet. Create the catalog, then add presets or items.' }}
50+ </SkEmpty>
51+ </SkPanel>
52+ </SkSection>
53+
54+ <ScopeAddPresetsModal
55+ v-model:open="addOpen"
56+ :presets="catalog.presets.value"
57+ :enabled="scope.config.value?.presets ?? []"
58+ :scope-name="scope.title.value"
59+ :catalog-exists="catalog.exists.value"
60+ @add="addPresets"
61+ />
62+</template>
added packages/ui/app/components/scope/ScopeResolveBox.vue +26 -0
new file mode 100644
@@ -0,0 +1,26 @@
1+<script setup lang="ts">
2+import type { ConflictResolution, PlanAction } from '@skilly/core'
3+
4+/** Inline drift resolution under an item row (mockup 10): headline, diff, and a staged choice per conflicting agent. */
5+defineProps<{ conflicts: PlanAction[], root: string, home?: boolean }>()
6+const scope = useScopeContext()
7+</script>
8+
9+<template>
10+ <div class="flex flex-col gap-4 rounded-2xl bg-sk-hover p-3.5">
11+ <div v-for="(a, i) in conflicts" :key="a.id" class="flex flex-col gap-3" :class="i > 0 && 'border-t border-sk-line pt-4'">
12+ <p class="m-0 flex flex-wrap items-baseline gap-x-2 font-medium">
13+ <span v-if="conflicts.length > 1" class="font-mono text-[11px] text-sk-faint">{{ AGENT_LABELS[a.agent] }}</span>
14+ {{ a.conflict ? CONFLICT_HEADLINE[a.conflict] : a.reason }}
15+ </p>
16+ <ScopeConflictCompare :action="a" :root="root" :local-label="home ? 'Home' : 'This project'" />
17+ <ScopeConflictChoice
18+ :model-value="scope.resolutionFor(a.id)"
19+ :action="a"
20+ :home="home"
21+ @update:model-value="(r: ConflictResolution) => scope.setResolution(a.id, r)"
22+ />
23+ </div>
24+ <p class="m-0 font-mono text-[11px] text-sk-faint">Nothing is written until you apply.</p>
25+ </div>
26+</template>
new file mode 100644
@@ -0,0 +1,26 @@
1+<script setup lang="ts">
2+import type { ConflictResolution, PlanAction } from '@skilly/core'
3+
4+/** Inline drift resolution under an item row (mockup 10): headline, diff, and a staged choice per conflicting agent. */
5+defineProps<{ conflicts: PlanAction[], root: string, home?: boolean }>()
6+const scope = useScopeContext()
7+</script>
8+
9+<template>
10+ <div class="flex flex-col gap-4 rounded-2xl bg-sk-hover p-3.5">
11+ <div v-for="(a, i) in conflicts" :key="a.id" class="flex flex-col gap-3" :class="i > 0 && 'border-t border-sk-line pt-4'">
12+ <p class="m-0 flex flex-wrap items-baseline gap-x-2 font-medium">
13+ <span v-if="conflicts.length > 1" class="font-mono text-[11px] text-sk-faint">{{ AGENT_LABELS[a.agent] }}</span>
14+ {{ a.conflict ? CONFLICT_HEADLINE[a.conflict] : a.reason }}
15+ </p>
16+ <ScopeConflictCompare :action="a" :root="root" :local-label="home ? 'Home' : 'This project'" />
17+ <ScopeConflictChoice
18+ :model-value="scope.resolutionFor(a.id)"
19+ :action="a"
20+ :home="home"
21+ @update:model-value="(r: ConflictResolution) => scope.setResolution(a.id, r)"
22+ />
23+ </div>
24+ <p class="m-0 font-mono text-[11px] text-sk-faint">Nothing is written until you apply.</p>
25+ </div>
26+</template>
added packages/ui/app/components/scope/ScopeReviewDrawer.vue +204 -0
new file mode 100644
@@ -0,0 +1,204 @@
1+<script setup lang="ts">
2+import type { ApplyResponse, ConflictResolution, PlanAction, PlanActionKind, ScopeState, SyncPlan } from '@skilly/core'
3+import type { HomeNames } from '../../utils/scopeRows'
4+
5+/**
6+ * Review & apply drawer (brief 4.1, 4.2): actions grouped by kind, files and config keys, plan warnings, missing
7+ * items, conflicts with the staged resolution and a side by side diff. Nothing is written until Apply.
8+ * The result (applied / skipped / errors) is shown in place.
9+ */
10+const props = defineProps<{ scopeId: string, plan: SyncPlan, home: boolean }>()
11+const open = defineModel<boolean>('open', { default: false })
12+const emit = defineEmits<{ applied: [state: ScopeState] }>()
13+
14+const scope = useScopeContext()
15+const { request } = useApi()
16+const toast = useToast()
17+const { home: homeInv } = useHomeInventory()
18+const catalog = useCatalog()
19+
20+const busy = ref(false)
21+const error = ref<string | null>(null)
22+const response = ref<ApplyResponse | null>(null)
23+const expanded = ref<Set<string>>(new Set())
24+let appliedActions: PlanAction[] = []
25+
26+watch(open, (v) => {
27+ if (!v) return
28+ response.value = null
29+ error.value = null
30+ expanded.value = new Set()
31+})
32+
33+const hideIds = computed(() => {
34+ const config = scope.config.value
35+ if (!config || props.home) return new Set<string>()
36+ const homeNames: HomeNames = { skill: new Set(), mcp: new Set() }
37+ for (const i of homeInv.value?.items ?? []) {
38+ if (i.type === 'skill') homeNames.skill.add(i.name)
39+ else if (i.type === 'mcp') homeNames.mcp.add(i.name)
40+ }
41+ const entryKeys = new Set(resolveItems(catalog.presets.value, config.presets, config.add).entries.map(e => `${e.type}/${e.name}`))
42+ return new Set(props.plan.actions.filter(a => isHideAction(a, config, entryKeys, homeNames)).map(a => a.id))
43+})
44+
45+const actions = computed(() => props.plan.actions.filter(isPending))
46+const groups = computed(() => PLAN_GROUPS
47+ .map(g => ({ ...g, rows: actions.value.filter(a => a.kind === g.kind) }))
48+ .filter(g => g.rows.length))
49+const conflicts = computed(() => actions.value.filter(a => a.kind === 'conflict'))
50+const summary = computed(() => {
51+ const n = actions.value.length
52+ const c = scope.openConflicts.value.length
53+ return `${n} change${n === 1 ? '' : 's'}${c ? ` · ${c} conflict${c === 1 ? '' : 's'}` : ''}`
54+})
55+
56+function toggle(id: string) {
57+ const s = new Set(expanded.value)
58+ if (s.has(id)) s.delete(id)
59+ else s.add(id)
60+ expanded.value = s
61+}
62+
63+const filesOf = (a: PlanAction) => a.files.length + a.configKeys.length
64+const isHide = (a: PlanAction) => hideIds.value.has(a.id)
65+const reasonOf = (a: PlanAction) => {
66+ if (isHide(a)) return a.kind === 'remove' ? 'Stops hiding this Home item in the project' : 'Hides this Home item in the project (Claude Code only)'
67+ return a.conflict ? `${CONFLICT_HEADLINE[a.conflict]}${a.catalogUpdated ? ' · the catalog changed too' : ''}` : a.reason
68+}
69+
70+async function apply() {
71+ appliedActions = [...props.plan.actions]
72+ busy.value = true
73+ error.value = null
74+ try {
75+ const res = await request<ApplyResponse>(`/api/scopes/${encodeURIComponent(props.scopeId)}/apply`, {
76+ method: 'POST',
77+ body: { resolutions: scope.resolutionBody() },
78+ silent: true
79+ })
80+ response.value = res
81+ emit('applied', res.state)
82+ const done = appliedActions.filter(a => res.result.applied.includes(a.id))
83+ if (!res.result.errors.length) {
84+ toast.add({
85+ title: `Applied ${done.length} change${done.length === 1 ? '' : 's'}`,
86+ description: `${filesTouched(done)} file${filesTouched(done) === 1 ? '' : 's'} written`,
87+ icon: 'i-lucide-check',
88+ color: 'success'
89+ })
90+ }
91+ } catch (e) {
92+ error.value = (e as Error).message
93+ } finally {
94+ busy.value = false
95+ }
96+}
97+
98+const labelOf = (id: string) => {
99+ const a = appliedActions.find(x => x.id === id)
100+ return a ? `${a.type}/${a.name} · ${AGENT_LABELS[a.agent]}` : id
101+}
102+const kindLabel = (k: PlanActionKind) => PLAN_GROUPS.find(g => g.kind === k)?.label ?? k
103+</script>
104+
105+<template>
106+ <SkDrawer v-model:open="open" width="xl">
107+ <template #header>
108+ <div class="flex min-w-0 flex-1 flex-col gap-0.5">
109+ <h2 class="m-0 text-base font-semibold">{{ response ? 'Changes applied' : 'Review changes' }}</h2>
110+ <p class="m-0 text-sk-muted">{{ response ? 'Result of the sync.' : summary }}</p>
111+ </div>
112+ <SkButton variant="ghost" icon="i-lucide-x" aria-label="Close" class="shrink-0" @click="open = false" />
113+ </template>
114+
115+ <!-- Result -->
116+ <div v-if="response" class="flex flex-col gap-4">
117+ <SkNote :tone="response.result.errors.length ? 'accent' : 'ok'">
118+ <span class="font-medium">{{ response.result.applied.length }} applied</span>
119+ · {{ response.result.skipped.length }} skipped · {{ response.result.errors.length }} error{{ response.result.errors.length === 1 ? '' : 's' }}
120+ </SkNote>
121+ <div v-if="response.result.errors.length" class="flex flex-col gap-2">
122+ <SkSectionLabel :count="response.result.errors.length">Errors</SkSectionLabel>
123+ <SkNote v-for="e in response.result.errors" :key="e.actionId" tone="accent"><span class="font-medium">{{ labelOf(e.actionId) }}</span>: {{ e.message }}</SkNote>
124+ </div>
125+ <SkTray v-if="response.result.skipped.length">
126+ <SkSectionLabel inset :count="response.result.skipped.length">Skipped</SkSectionLabel>
127+ <SkRowCard v-for="s in response.result.skipped" :key="s" class="text-sk-muted">{{ labelOf(s) }}</SkRowCard>
128+ </SkTray>
129+ <SkTray v-if="response.result.applied.length">
130+ <SkSectionLabel inset :count="response.result.applied.length">Applied</SkSectionLabel>
131+ <SkRowCard v-for="s in response.result.applied" :key="s">{{ labelOf(s) }}</SkRowCard>
132+ </SkTray>
133+ </div>
134+
135+ <!-- Review -->
136+ <div v-else class="flex flex-col gap-4">
137+ <SkNote v-if="error" tone="accent"><span class="font-medium">Apply failed.</span> {{ error }}</SkNote>
138+ <SkNote v-if="home" tone="accent">This writes into your Home agent folders (~/.claude, ~/.agents, ~/.codex). Every project sees these files.</SkNote>
139+ <SkNote v-for="w in plan.warnings" :key="w" tone="accent">{{ w }}</SkNote>
140+ <SkNote v-if="plan.missing.length" tone="accent">
141+ <p class="m-0">{{ plan.missing.length }} item{{ plan.missing.length === 1 ? '' : 's' }} referenced by the configuration but not found in the catalog. They will be skipped.</p>
142+ <div class="mt-2 flex flex-wrap gap-1.5">
143+ <SkItemChip v-for="m in plan.missing" :key="`${m.type}/${m.name}`" :name="m.name" :type="m.type" class="text-sk-text" />
144+ </div>
145+ </SkNote>
146+
147+ <SkTray v-for="g in groups" :key="g.kind">
148+ <SkSectionLabel inset :count="g.rows.length">{{ g.label }}</SkSectionLabel>
149+ <SkRowCard v-for="a in g.rows" :key="a.id" class="flex flex-col gap-2.5">
150+ <div class="flex items-start gap-3">
151+ <div class="min-w-0 flex-1">
152+ <div class="flex flex-wrap items-baseline gap-x-2">
153+ <span class="font-medium">{{ a.name }}</span>
154+ <SkTag v-if="isHide(a)" mono>hide</SkTag>
155+ <SkTypeTag v-else :type="a.type" />
156+ <span class="text-xs text-sk-muted">{{ AGENT_LABELS[a.agent] }}</span>
157+ <SkStatus v-if="a.catalogUpdated && a.kind !== 'conflict'" tone="info" label="Catalog updated" small class="text-xs text-sk-muted" />
158+ </div>
159+ <p class="m-0 text-xs text-pretty text-sk-muted">{{ reasonOf(a) }}</p>
160+ </div>
161+ <SkButton
162+ v-if="filesOf(a) || a.kind === 'conflict'"
163+ variant="ghost"
164+ size="xs"
165+ :label="a.kind === 'conflict' ? 'Compare' : `${filesOf(a)} file${filesOf(a) === 1 ? '' : 's'}`"
166+ :trailing-icon="expanded.has(a.id) ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'"
167+ @click="toggle(a.id)"
168+ />
169+ </div>
170+
171+ <ScopeConflictChoice
172+ v-if="a.kind === 'conflict'"
173+ :model-value="scope.resolutionFor(a.id)"
174+ :action="a"
175+ :home="home"
176+ @update:model-value="(r: ConflictResolution) => scope.setResolution(a.id, r)"
177+ />
178+
179+ <template v-if="expanded.has(a.id)">
180+ <ul v-if="filesOf(a)" class="m-0 flex list-none flex-col gap-0.5 p-0 font-mono text-[11px] text-sk-faint">
181+ <li v-for="f in a.files" :key="f" class="break-all">{{ f }}</li>
182+ <li v-for="c in a.configKeys" :key="c.file + c.key" class="break-all">{{ c.file }} : {{ c.key }}</li>
183+ </ul>
184+ <ScopeConflictCompare v-if="a.kind === 'conflict'" :action="a" :root="plan.root" mode="split" :local-label="home ? 'Home' : 'This project'" />
185+ </template>
186+ </SkRowCard>
187+ </SkTray>
188+
189+ <SkEmpty v-if="!groups.length" text="Nothing to apply." />
190+ </div>
191+
192+ <template #footer>
193+ <template v-if="response">
194+ <span class="mr-auto" />
195+ <SkButton variant="primary" label="Close" @click="open = false" />
196+ </template>
197+ <template v-else>
198+ <span class="mr-auto font-mono text-[11px] text-sk-faint">Nothing is written until you apply.</span>
199+ <SkButton label="Cancel" @click="open = false" />
200+ <SkButton variant="primary" :loading="busy" :disabled="!actions.length" :label="actions.length ? `Apply ${actions.length} change${actions.length === 1 ? '' : 's'}` : 'Apply'" @click="apply" />
201+ </template>
202+ </template>
203+ </SkDrawer>
204+</template>
new file mode 100644
@@ -0,0 +1,204 @@
1+<script setup lang="ts">
2+import type { ApplyResponse, ConflictResolution, PlanAction, PlanActionKind, ScopeState, SyncPlan } from '@skilly/core'
3+import type { HomeNames } from '../../utils/scopeRows'
4+
5+/**
6+ * Review & apply drawer (brief 4.1, 4.2): actions grouped by kind, files and config keys, plan warnings, missing
7+ * items, conflicts with the staged resolution and a side by side diff. Nothing is written until Apply.
8+ * The result (applied / skipped / errors) is shown in place.
9+ */
10+const props = defineProps<{ scopeId: string, plan: SyncPlan, home: boolean }>()
11+const open = defineModel<boolean>('open', { default: false })
12+const emit = defineEmits<{ applied: [state: ScopeState] }>()
13+
14+const scope = useScopeContext()
15+const { request } = useApi()
16+const toast = useToast()
17+const { home: homeInv } = useHomeInventory()
18+const catalog = useCatalog()
19+
20+const busy = ref(false)
21+const error = ref<string | null>(null)
22+const response = ref<ApplyResponse | null>(null)
23+const expanded = ref<Set<string>>(new Set())
24+let appliedActions: PlanAction[] = []
25+
26+watch(open, (v) => {
27+ if (!v) return
28+ response.value = null
29+ error.value = null
30+ expanded.value = new Set()
31+})
32+
33+const hideIds = computed(() => {
34+ const config = scope.config.value
35+ if (!config || props.home) return new Set<string>()
36+ const homeNames: HomeNames = { skill: new Set(), mcp: new Set() }
37+ for (const i of homeInv.value?.items ?? []) {
38+ if (i.type === 'skill') homeNames.skill.add(i.name)
39+ else if (i.type === 'mcp') homeNames.mcp.add(i.name)
40+ }
41+ const entryKeys = new Set(resolveItems(catalog.presets.value, config.presets, config.add).entries.map(e => `${e.type}/${e.name}`))
42+ return new Set(props.plan.actions.filter(a => isHideAction(a, config, entryKeys, homeNames)).map(a => a.id))
43+})
44+
45+const actions = computed(() => props.plan.actions.filter(isPending))
46+const groups = computed(() => PLAN_GROUPS
47+ .map(g => ({ ...g, rows: actions.value.filter(a => a.kind === g.kind) }))
48+ .filter(g => g.rows.length))
49+const conflicts = computed(() => actions.value.filter(a => a.kind === 'conflict'))
50+const summary = computed(() => {
51+ const n = actions.value.length
52+ const c = scope.openConflicts.value.length
53+ return `${n} change${n === 1 ? '' : 's'}${c ? ` · ${c} conflict${c === 1 ? '' : 's'}` : ''}`
54+})
55+
56+function toggle(id: string) {
57+ const s = new Set(expanded.value)
58+ if (s.has(id)) s.delete(id)
59+ else s.add(id)
60+ expanded.value = s
61+}
62+
63+const filesOf = (a: PlanAction) => a.files.length + a.configKeys.length
64+const isHide = (a: PlanAction) => hideIds.value.has(a.id)
65+const reasonOf = (a: PlanAction) => {
66+ if (isHide(a)) return a.kind === 'remove' ? 'Stops hiding this Home item in the project' : 'Hides this Home item in the project (Claude Code only)'
67+ return a.conflict ? `${CONFLICT_HEADLINE[a.conflict]}${a.catalogUpdated ? ' · the catalog changed too' : ''}` : a.reason
68+}
69+
70+async function apply() {
71+ appliedActions = [...props.plan.actions]
72+ busy.value = true
73+ error.value = null
74+ try {
75+ const res = await request<ApplyResponse>(`/api/scopes/${encodeURIComponent(props.scopeId)}/apply`, {
76+ method: 'POST',
77+ body: { resolutions: scope.resolutionBody() },
78+ silent: true
79+ })
80+ response.value = res
81+ emit('applied', res.state)
82+ const done = appliedActions.filter(a => res.result.applied.includes(a.id))
83+ if (!res.result.errors.length) {
84+ toast.add({
85+ title: `Applied ${done.length} change${done.length === 1 ? '' : 's'}`,
86+ description: `${filesTouched(done)} file${filesTouched(done) === 1 ? '' : 's'} written`,
87+ icon: 'i-lucide-check',
88+ color: 'success'
89+ })
90+ }
91+ } catch (e) {
92+ error.value = (e as Error).message
93+ } finally {
94+ busy.value = false
95+ }
96+}
97+
98+const labelOf = (id: string) => {
99+ const a = appliedActions.find(x => x.id === id)
100+ return a ? `${a.type}/${a.name} · ${AGENT_LABELS[a.agent]}` : id
101+}
102+const kindLabel = (k: PlanActionKind) => PLAN_GROUPS.find(g => g.kind === k)?.label ?? k
103+</script>
104+
105+<template>
106+ <SkDrawer v-model:open="open" width="xl">
107+ <template #header>
108+ <div class="flex min-w-0 flex-1 flex-col gap-0.5">
109+ <h2 class="m-0 text-base font-semibold">{{ response ? 'Changes applied' : 'Review changes' }}</h2>
110+ <p class="m-0 text-sk-muted">{{ response ? 'Result of the sync.' : summary }}</p>
111+ </div>
112+ <SkButton variant="ghost" icon="i-lucide-x" aria-label="Close" class="shrink-0" @click="open = false" />
113+ </template>
114+
115+ <!-- Result -->
116+ <div v-if="response" class="flex flex-col gap-4">
117+ <SkNote :tone="response.result.errors.length ? 'accent' : 'ok'">
118+ <span class="font-medium">{{ response.result.applied.length }} applied</span>
119+ · {{ response.result.skipped.length }} skipped · {{ response.result.errors.length }} error{{ response.result.errors.length === 1 ? '' : 's' }}
120+ </SkNote>
121+ <div v-if="response.result.errors.length" class="flex flex-col gap-2">
122+ <SkSectionLabel :count="response.result.errors.length">Errors</SkSectionLabel>
123+ <SkNote v-for="e in response.result.errors" :key="e.actionId" tone="accent"><span class="font-medium">{{ labelOf(e.actionId) }}</span>: {{ e.message }}</SkNote>
124+ </div>
125+ <SkTray v-if="response.result.skipped.length">
126+ <SkSectionLabel inset :count="response.result.skipped.length">Skipped</SkSectionLabel>
127+ <SkRowCard v-for="s in response.result.skipped" :key="s" class="text-sk-muted">{{ labelOf(s) }}</SkRowCard>
128+ </SkTray>
129+ <SkTray v-if="response.result.applied.length">
130+ <SkSectionLabel inset :count="response.result.applied.length">Applied</SkSectionLabel>
131+ <SkRowCard v-for="s in response.result.applied" :key="s">{{ labelOf(s) }}</SkRowCard>
132+ </SkTray>
133+ </div>
134+
135+ <!-- Review -->
136+ <div v-else class="flex flex-col gap-4">
137+ <SkNote v-if="error" tone="accent"><span class="font-medium">Apply failed.</span> {{ error }}</SkNote>
138+ <SkNote v-if="home" tone="accent">This writes into your Home agent folders (~/.claude, ~/.agents, ~/.codex). Every project sees these files.</SkNote>
139+ <SkNote v-for="w in plan.warnings" :key="w" tone="accent">{{ w }}</SkNote>
140+ <SkNote v-if="plan.missing.length" tone="accent">
141+ <p class="m-0">{{ plan.missing.length }} item{{ plan.missing.length === 1 ? '' : 's' }} referenced by the configuration but not found in the catalog. They will be skipped.</p>
142+ <div class="mt-2 flex flex-wrap gap-1.5">
143+ <SkItemChip v-for="m in plan.missing" :key="`${m.type}/${m.name}`" :name="m.name" :type="m.type" class="text-sk-text" />
144+ </div>
145+ </SkNote>
146+
147+ <SkTray v-for="g in groups" :key="g.kind">
148+ <SkSectionLabel inset :count="g.rows.length">{{ g.label }}</SkSectionLabel>
149+ <SkRowCard v-for="a in g.rows" :key="a.id" class="flex flex-col gap-2.5">
150+ <div class="flex items-start gap-3">
151+ <div class="min-w-0 flex-1">
152+ <div class="flex flex-wrap items-baseline gap-x-2">
153+ <span class="font-medium">{{ a.name }}</span>
154+ <SkTag v-if="isHide(a)" mono>hide</SkTag>
155+ <SkTypeTag v-else :type="a.type" />
156+ <span class="text-xs text-sk-muted">{{ AGENT_LABELS[a.agent] }}</span>
157+ <SkStatus v-if="a.catalogUpdated && a.kind !== 'conflict'" tone="info" label="Catalog updated" small class="text-xs text-sk-muted" />
158+ </div>
159+ <p class="m-0 text-xs text-pretty text-sk-muted">{{ reasonOf(a) }}</p>
160+ </div>
161+ <SkButton
162+ v-if="filesOf(a) || a.kind === 'conflict'"
163+ variant="ghost"
164+ size="xs"
165+ :label="a.kind === 'conflict' ? 'Compare' : `${filesOf(a)} file${filesOf(a) === 1 ? '' : 's'}`"
166+ :trailing-icon="expanded.has(a.id) ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'"
167+ @click="toggle(a.id)"
168+ />
169+ </div>
170+
171+ <ScopeConflictChoice
172+ v-if="a.kind === 'conflict'"
173+ :model-value="scope.resolutionFor(a.id)"
174+ :action="a"
175+ :home="home"
176+ @update:model-value="(r: ConflictResolution) => scope.setResolution(a.id, r)"
177+ />
178+
179+ <template v-if="expanded.has(a.id)">
180+ <ul v-if="filesOf(a)" class="m-0 flex list-none flex-col gap-0.5 p-0 font-mono text-[11px] text-sk-faint">
181+ <li v-for="f in a.files" :key="f" class="break-all">{{ f }}</li>
182+ <li v-for="c in a.configKeys" :key="c.file + c.key" class="break-all">{{ c.file }} : {{ c.key }}</li>
183+ </ul>
184+ <ScopeConflictCompare v-if="a.kind === 'conflict'" :action="a" :root="plan.root" mode="split" :local-label="home ? 'Home' : 'This project'" />
185+ </template>
186+ </SkRowCard>
187+ </SkTray>
188+
189+ <SkEmpty v-if="!groups.length" text="Nothing to apply." />
190+ </div>
191+
192+ <template #footer>
193+ <template v-if="response">
194+ <span class="mr-auto" />
195+ <SkButton variant="primary" label="Close" @click="open = false" />
196+ </template>
197+ <template v-else>
198+ <span class="mr-auto font-mono text-[11px] text-sk-faint">Nothing is written until you apply.</span>
199+ <SkButton label="Cancel" @click="open = false" />
200+ <SkButton variant="primary" :loading="busy" :disabled="!actions.length" :label="actions.length ? `Apply ${actions.length} change${actions.length === 1 ? '' : 's'}` : 'Apply'" @click="apply" />
201+ </template>
202+ </template>
203+ </SkDrawer>
204+</template>
added packages/ui/app/components/scope/ScopeRowStatus.vue +41 -0
new file mode 100644
@@ -0,0 +1,41 @@
1+<script setup lang="ts">
2+import type { RowAction, ScopeRow } from '../../utils/scopeRows'
3+
4+/**
5+ * Status cell of an item row: one status when the agents agree, otherwise one mono mini status per agent.
6+ * A conflict whose resolution was chosen shows "Resolution: push to catalog" (staged, executed by Apply).
7+ */
8+const props = defineProps<{ row: ScopeRow }>()
9+const emit = defineEmits<{ action: [kind: RowAction] }>()
10+const scope = useScopeContext()
11+
12+const ACTION_LABEL: Record<RowAction, string> = { resolve: 'Resolve', review: 'Review', import: 'Import' }
13+
14+const merged = computed(() => mergeStatuses(props.row.statuses))
15+const staged = computed(() => props.row.conflicts.length > 0 && props.row.conflicts.every(a => scope.decided.value.has(a.id)))
16+const stagedLabel = computed(() => {
17+ const picks = [...new Set(props.row.conflicts.map(a => RESOLUTION_LABEL[scope.resolutionFor(a.id)]))]
18+ return picks.length === 1 ? `Resolution: ${picks[0]}` : 'Resolutions staged'
19+})
20+</script>
21+
22+<template>
23+ <div class="flex min-w-0 flex-col items-end gap-0.5 text-right">
24+ <SkStatus v-if="staged" tone="info" :label="stagedLabel" class="font-mono text-xs">
25+ <template #action><SkLinkAction @click.stop="emit('action', 'resolve')">Change</SkLinkAction></template>
26+ </SkStatus>
27+ <SkStatus v-else-if="merged" :tone="merged.tone" :label="merged.label">
28+ <template #action>
29+ <SkLinkAction v-if="merged.action" @click.stop="emit('action', merged.action)">{{ ACTION_LABEL[merged.action] }}</SkLinkAction>
30+ </template>
31+ </SkStatus>
32+ <template v-else>
33+ <span v-for="s in row.statuses" :key="s.agent" class="inline-flex items-center gap-1.5 font-mono text-[11px] whitespace-nowrap">
34+ <span class="text-sk-faint">{{ s.agent }}</span>
35+ <SkStatusDot :tone="s.tone" small />
36+ <span class="text-sk-text">{{ s.short }}</span>
37+ <SkLinkAction v-if="s.action" class="font-sans text-xs" @click.stop="emit('action', s.action)">{{ ACTION_LABEL[s.action] }}</SkLinkAction>
38+ </span>
39+ </template>
40+ </div>
41+</template>
new file mode 100644
@@ -0,0 +1,41 @@
1+<script setup lang="ts">
2+import type { RowAction, ScopeRow } from '../../utils/scopeRows'
3+
4+/**
5+ * Status cell of an item row: one status when the agents agree, otherwise one mono mini status per agent.
6+ * A conflict whose resolution was chosen shows "Resolution: push to catalog" (staged, executed by Apply).
7+ */
8+const props = defineProps<{ row: ScopeRow }>()
9+const emit = defineEmits<{ action: [kind: RowAction] }>()
10+const scope = useScopeContext()
11+
12+const ACTION_LABEL: Record<RowAction, string> = { resolve: 'Resolve', review: 'Review', import: 'Import' }
13+
14+const merged = computed(() => mergeStatuses(props.row.statuses))
15+const staged = computed(() => props.row.conflicts.length > 0 && props.row.conflicts.every(a => scope.decided.value.has(a.id)))
16+const stagedLabel = computed(() => {
17+ const picks = [...new Set(props.row.conflicts.map(a => RESOLUTION_LABEL[scope.resolutionFor(a.id)]))]
18+ return picks.length === 1 ? `Resolution: ${picks[0]}` : 'Resolutions staged'
19+})
20+</script>
21+
22+<template>
23+ <div class="flex min-w-0 flex-col items-end gap-0.5 text-right">
24+ <SkStatus v-if="staged" tone="info" :label="stagedLabel" class="font-mono text-xs">
25+ <template #action><SkLinkAction @click.stop="emit('action', 'resolve')">Change</SkLinkAction></template>
26+ </SkStatus>
27+ <SkStatus v-else-if="merged" :tone="merged.tone" :label="merged.label">
28+ <template #action>
29+ <SkLinkAction v-if="merged.action" @click.stop="emit('action', merged.action)">{{ ACTION_LABEL[merged.action] }}</SkLinkAction>
30+ </template>
31+ </SkStatus>
32+ <template v-else>
33+ <span v-for="s in row.statuses" :key="s.agent" class="inline-flex items-center gap-1.5 font-mono text-[11px] whitespace-nowrap">
34+ <span class="text-sk-faint">{{ s.agent }}</span>
35+ <SkStatusDot :tone="s.tone" small />
36+ <span class="text-sk-text">{{ s.short }}</span>
37+ <SkLinkAction v-if="s.action" class="font-sans text-xs" @click.stop="emit('action', s.action)">{{ ACTION_LABEL[s.action] }}</SkLinkAction>
38+ </span>
39+ </template>
40+ </div>
41+</template>
added packages/ui/app/components/scope/ScopeScanErrors.vue +16 -0
new file mode 100644
@@ -0,0 +1,16 @@
1+<script setup lang="ts">
2+import type { ScanError } from '@skilly/core'
3+
4+/** Details of the items the scanners could not read (inside a clay SkNote). */
5+defineProps<{ errors: ScanError[], root: string }>()
6+</script>
7+
8+<template>
9+ <ul class="m-0 mt-2 flex list-none flex-col gap-1.5 p-0 text-xs">
10+ <li v-for="(e, i) in errors" :key="i">
11+ <span v-if="e.agent" class="mr-1.5 font-mono text-[11px] opacity-80">{{ AGENT_LABELS[e.agent] }}</span>
12+ <span class="font-mono break-all" :title="e.path">{{ relativeTo(root, e.path) }}</span>
13+ <span class="block opacity-80">{{ e.message }}</span>
14+ </li>
15+ </ul>
16+</template>
new file mode 100644
@@ -0,0 +1,16 @@
1+<script setup lang="ts">
2+import type { ScanError } from '@skilly/core'
3+
4+/** Details of the items the scanners could not read (inside a clay SkNote). */
5+defineProps<{ errors: ScanError[], root: string }>()
6+</script>
7+
8+<template>
9+ <ul class="m-0 mt-2 flex list-none flex-col gap-1.5 p-0 text-xs">
10+ <li v-for="(e, i) in errors" :key="i">
11+ <span v-if="e.agent" class="mr-1.5 font-mono text-[11px] opacity-80">{{ AGENT_LABELS[e.agent] }}</span>
12+ <span class="font-mono break-all" :title="e.path">{{ relativeTo(root, e.path) }}</span>
13+ <span class="block opacity-80">{{ e.message }}</span>
14+ </li>
15+ </ul>
16+</template>
added packages/ui/app/components/scope/ScopeTargets.vue +46 -0
new file mode 100644
@@ -0,0 +1,46 @@
1+<script setup lang="ts">
2+import type { AgentId } from '@skilly/core'
3+
4+/** "Writes for: Claude Code, Codex" with a popover to change the target agents (ScopeConfig.targets). */
5+const scope = useScopeContext()
6+const AGENT_LIST: AgentId[] = ['claude', 'codex']
7+
8+const label = computed(() => {
9+ const t = scope.config.value?.targets ?? []
10+ return t.length ? t.map(a => AGENT_LABELS[a]).join(', ') : 'no agent'
11+})
12+
13+function toggle(a: AgentId, on: boolean | 'indeterminate') {
14+ scope.edit((c) => {
15+ c.targets = on === true ? [...new Set([...c.targets, a])] : c.targets.filter(t => t !== a)
16+ })
17+}
18+</script>
19+
20+<template>
21+ <UPopover :content="{ align: 'start' }">
22+ <button
23+ type="button"
24+ class="inline-flex cursor-pointer items-center gap-1 rounded-md border-0 bg-transparent p-0 font-mono text-xs underline decoration-dotted underline-offset-4 hover:text-sk-text focus-visible:outline-2 focus-visible:outline-sk-accent"
25+ :class="scope.config.value?.targets.length ? 'text-sk-faint' : 'text-sk-accent-text'"
26+ aria-label="Change the agents this project writes for"
27+ >
28+ Writes for {{ label }}
29+ <UIcon name="i-lucide-chevron-down" class="size-3" />
30+ </button>
31+ <template #content>
32+ <div class="flex w-64 flex-col gap-2.5 p-3.5 text-[13px]">
33+ <p class="m-0 font-semibold">Write files for</p>
34+ <UCheckbox
35+ v-for="a in AGENT_LIST"
36+ :key="a"
37+ :model-value="scope.config.value?.targets.includes(a)"
38+ :label="AGENT_LABELS[a]"
39+ :description="a === 'claude' ? '.claude/ · CLAUDE.md' : '.agents/ · .codex/ · AGENTS.md'"
40+ @update:model-value="(v) => toggle(a, v)"
41+ />
42+ <p v-if="!scope.config.value?.targets.length" class="m-0 text-xs text-sk-accent-text">No target agent: nothing will be written.</p>
43+ </div>
44+ </template>
45+ </UPopover>
46+</template>
new file mode 100644
@@ -0,0 +1,46 @@
1+<script setup lang="ts">
2+import type { AgentId } from '@skilly/core'
3+
4+/** "Writes for: Claude Code, Codex" with a popover to change the target agents (ScopeConfig.targets). */
5+const scope = useScopeContext()
6+const AGENT_LIST: AgentId[] = ['claude', 'codex']
7+
8+const label = computed(() => {
9+ const t = scope.config.value?.targets ?? []
10+ return t.length ? t.map(a => AGENT_LABELS[a]).join(', ') : 'no agent'
11+})
12+
13+function toggle(a: AgentId, on: boolean | 'indeterminate') {
14+ scope.edit((c) => {
15+ c.targets = on === true ? [...new Set([...c.targets, a])] : c.targets.filter(t => t !== a)
16+ })
17+}
18+</script>
19+
20+<template>
21+ <UPopover :content="{ align: 'start' }">
22+ <button
23+ type="button"
24+ class="inline-flex cursor-pointer items-center gap-1 rounded-md border-0 bg-transparent p-0 font-mono text-xs underline decoration-dotted underline-offset-4 hover:text-sk-text focus-visible:outline-2 focus-visible:outline-sk-accent"
25+ :class="scope.config.value?.targets.length ? 'text-sk-faint' : 'text-sk-accent-text'"
26+ aria-label="Change the agents this project writes for"
27+ >
28+ Writes for {{ label }}
29+ <UIcon name="i-lucide-chevron-down" class="size-3" />
30+ </button>
31+ <template #content>
32+ <div class="flex w-64 flex-col gap-2.5 p-3.5 text-[13px]">
33+ <p class="m-0 font-semibold">Write files for</p>
34+ <UCheckbox
35+ v-for="a in AGENT_LIST"
36+ :key="a"
37+ :model-value="scope.config.value?.targets.includes(a)"
38+ :label="AGENT_LABELS[a]"
39+ :description="a === 'claude' ? '.claude/ · CLAUDE.md' : '.agents/ · .codex/ · AGENTS.md'"
40+ @update:model-value="(v) => toggle(a, v)"
41+ />
42+ <p v-if="!scope.config.value?.targets.length" class="m-0 text-xs text-sk-accent-text">No target agent: nothing will be written.</p>
43+ </div>
44+ </template>
45+ </UPopover>
46+</template>
modified packages/ui/app/composables/useCatalogGit.ts +10 -2
@@ -2,12 +2,20 @@ import type { GitStatus } from '@skilly/core'
22
33 export type GitAction = 'remote' | 'commit' | 'pull' | 'push' | 'sync' | 'resolve'
44
5-/** Catalog git actions shared by the sidebar indicator and the git dialog. */
5+/**
6+ * Catalog git actions shared by the header pill and the Sync page (`/sync`).
7+ * `open` is kept for older callers: reading it tells whether the Sync page is shown, setting it to true opens the page
8+ * (there is no git dialog any more).
9+ */
610 export function useCatalogGit() {
711 const { request } = useApi()
812 const { setGit, refresh } = useCatalog()
913 const toast = useToast()
10- const open = useState('skilly-git-open', () => false)
14+ const route = useRoute()
15+ const open = computed({
16+ get: () => route.path === '/sync',
17+ set: (v: boolean) => { if (v && route.path !== '/sync') navigateTo('/sync') }
18+ })
1119 const busy = useState<GitAction | null>('skilly-git-busy', () => null)
1220 const error = useState<string | null>('skilly-git-error', () => null)
1321
@@ -2,12 +2,20 @@ import type { GitStatus } from '@skilly/core'
2 2
3 export type GitAction = 'remote' | 'commit' | 'pull' | 'push' | 'sync' | 'resolve'3 export type GitAction = 'remote' | 'commit' | 'pull' | 'push' | 'sync' | 'resolve'
4 4
5-/** Catalog git actions shared by the sidebar indicator and the git dialog. */5+/**
6+ * Catalog git actions shared by the header pill and the Sync page (`/sync`).
7+ * `open` is kept for older callers: reading it tells whether the Sync page is shown, setting it to true opens the page
8+ * (there is no git dialog any more).
9+ */
6 export function useCatalogGit() {10 export function useCatalogGit() {
7 const { request } = useApi()11 const { request } = useApi()
8 const { setGit, refresh } = useCatalog()12 const { setGit, refresh } = useCatalog()
9 const toast = useToast()13 const toast = useToast()
10- const open = useState('skilly-git-open', () => false)14+ const route = useRoute()
15+ const open = computed({
16+ get: () => route.path === '/sync',
17+ set: (v: boolean) => { if (v && route.path !== '/sync') navigateTo('/sync') }
18+ })
11 const busy = useState<GitAction | null>('skilly-git-busy', () => null)19 const busy = useState<GitAction | null>('skilly-git-busy', () => null)
12 const error = useState<string | null>('skilly-git-error', () => null)20 const error = useState<string | null>('skilly-git-error', () => null)
13 21
added packages/ui/app/composables/useScopeState.ts +202 -0
new file mode 100644
@@ -0,0 +1,202 @@
1+import type { AgentId, ConflictResolution, InventoryItem, ScopeConfig, ScopeState, SyncPlan } from '@skilly/core'
2+import type { InjectionKey } from 'vue'
3+
4+/**
5+ * State of one scope page (Home or a project): `GET /api/scopes/:scope/state`, the local editable copy of the
6+ * config (saved debounced with `PUT .../config`), the staged conflict resolutions, and the overlays the page owns
7+ * (item drawer, review drawer, import and remove-from-global modals).
8+ *
9+ * `ScopePage` creates it and provides it; the scope components read it with `useScopeContext()`.
10+ * Nothing is written to disk by this composable except `skilly.json` / `home.json` (the config); files change on Apply only.
11+ */
12+export function createScope(kind: 'home' | 'project', projectId: () => string | undefined) {
13+ const { request } = useApi()
14+ const { projects, refresh: refreshProjects } = useProjects()
15+ const catalog = useCatalog()
16+ const toast = useToast()
17+
18+ const scopeId = computed(() => (kind === 'home' ? 'home' : projectId() ?? ''))
19+ const state = ref<ScopeState | null>(null)
20+ const config = ref<ScopeConfig | null>(null)
21+ const loading = ref(true)
22+ const error = ref<string | null>(null)
23+ const saving = ref(false)
24+
25+ const inventory = computed(() => state.value?.inventory ?? null)
26+ const statuses = computed(() => state.value?.statuses ?? {})
27+ const plan = computed<SyncPlan | null>(() => state.value?.plan ?? null)
28+ const project = computed(() => (kind === 'project' ? projects.value.find(p => p.id === scopeId.value) : undefined))
29+ const folderMissing = computed(() => kind === 'project' && !!project.value && !project.value.exists)
30+ const title = computed(() => (kind === 'home' ? 'Home' : project.value?.name ?? scopeId.value))
31+ const rootPath = computed(() => inventory.value?.root ?? project.value?.path ?? '')
32+
33+ async function load(showLoading = true) {
34+ if (showLoading) loading.value = true
35+ try {
36+ state.value = await request<ScopeState>(`/api/scopes/${encodeURIComponent(scopeId.value)}/state`, { silent: true })
37+ config.value = state.value.config ? cloneConfig(state.value.config) : null
38+ error.value = null
39+ } catch (e) {
40+ error.value = (e as Error).message
41+ // The folder may have disappeared: refresh the summaries so `exists` is up to date.
42+ if (kind === 'project') await refreshProjects()
43+ } finally {
44+ loading.value = false
45+ }
46+ }
47+
48+ async function rescan() {
49+ await load(false)
50+ if (!error.value) toast.add({ title: 'Rescan complete', color: 'success', icon: 'i-lucide-check' })
51+ }
52+
53+ /* ---------------------------------------------------------------- config autosave */
54+
55+ let timer: ReturnType<typeof setTimeout> | undefined
56+ let seq = 0
57+ let adopted = 0
58+ let inflight: Promise<void> = Promise.resolve()
59+
60+ async function runSave(mine: number) {
61+ const body = config.value
62+ if (!body) return
63+ try {
64+ const res = await request<ScopeState>(`/api/scopes/${encodeURIComponent(scopeId.value)}/config`, { method: 'PUT', body, silent: true })
65+ // Ignore answers that arrive out of order.
66+ if (mine >= adopted) {
67+ adopted = mine
68+ state.value = res
69+ // Only adopt the server copy when no newer edit happened meanwhile.
70+ if (mine === seq) config.value = res.config ? cloneConfig(res.config) : body
71+ }
72+ } catch (e) {
73+ toast.add({ title: 'Could not save the configuration', description: (e as Error).message, color: 'error', icon: 'i-lucide-circle-alert' })
74+ if (mine === seq) await load(false)
75+ } finally {
76+ if (mine === seq) saving.value = false
77+ }
78+ }
79+
80+ /** Replaces the config: shown at once, saved in the background (debounced unless `immediate`). */
81+ function saveConfig(next: ScopeConfig, immediate = false) {
82+ config.value = next
83+ const mine = ++seq
84+ saving.value = true
85+ clearTimeout(timer)
86+ timer = undefined
87+ if (immediate) inflight = runSave(mine)
88+ else timer = setTimeout(() => { timer = undefined; inflight = runSave(mine) }, 400)
89+ }
90+
91+ /** Runs a pending save now and waits for it (before opening the review). */
92+ async function flush() {
93+ if (timer) {
94+ clearTimeout(timer)
95+ timer = undefined
96+ inflight = runSave(seq)
97+ }
98+ await inflight
99+ }
100+
101+ /** Edits a copy of the config and saves it. */
102+ function edit(fn: (c: ScopeConfig) => void) {
103+ if (!config.value) return
104+ const c = cloneConfig(config.value)
105+ fn(c)
106+ saveConfig(c)
107+ }
108+
109+ onBeforeUnmount(() => clearTimeout(timer))
110+
111+ /* ---------------------------------------------------------------- conflict resolutions (staged) */
112+
113+ const resolutions = ref<Record<string, ConflictResolution>>({})
114+ /** Actions for which the user explicitly chose a resolution (the default keep-local is not a decision). */
115+ const decided = ref<Set<string>>(new Set())
116+
117+ function resolutionFor(actionId: string): ConflictResolution {
118+ return resolutions.value[actionId] ?? 'keep-local'
119+ }
120+ function setResolution(actionId: string, r: ConflictResolution) {
121+ resolutions.value = { ...resolutions.value, [actionId]: r }
122+ decided.value = new Set([...decided.value, actionId])
123+ }
124+ /** Resolutions sent to Apply: one per conflict of the plan, keep-local unless chosen otherwise. */
125+ function resolutionBody(): Record<string, ConflictResolution> {
126+ return Object.fromEntries((plan.value?.actions ?? []).filter(a => a.kind === 'conflict').map(a => [a.id, resolutionFor(a.id)]))
127+ }
128+ watch(plan, (p) => {
129+ const ids = new Set((p?.actions ?? []).map(a => a.id))
130+ resolutions.value = Object.fromEntries(Object.entries(resolutions.value).filter(([id]) => ids.has(id)))
131+ decided.value = new Set([...decided.value].filter(id => ids.has(id)))
132+ })
133+
134+ const pending = computed(() => (plan.value?.actions ?? []).filter(isPending))
135+ const conflictActions = computed(() => pending.value.filter(a => a.kind === 'conflict'))
136+ /** Conflicts still waiting for a decision. */
137+ const openConflicts = computed(() => conflictActions.value.filter(a => !decided.value.has(a.id)))
138+
139+ /* ---------------------------------------------------------------- overlays owned by the page */
140+
141+ const selectedItem = ref<InventoryItem | null>(null)
142+ const itemOpen = ref(false)
143+ const reviewOpen = ref(false)
144+ const importItems = ref<InventoryItem[]>([])
145+ const importOpen = ref(false)
146+ const moveItems = ref<InventoryItem[]>([])
147+ const moveOpen = ref(false)
148+
149+ function openItem(item: InventoryItem) {
150+ selectedItem.value = item
151+ itemOpen.value = true
152+ }
153+ function askImport(items: InventoryItem[]) {
154+ importItems.value = items
155+ importOpen.value = true
156+ }
157+ function askMove(items: InventoryItem[]) {
158+ moveItems.value = items
159+ moveOpen.value = true
160+ }
161+ async function openReview() {
162+ await flush()
163+ reviewOpen.value = true
164+ }
165+
166+ /** After an import / move: refresh this scope, the catalog and the project summaries. */
167+ async function afterCatalogChange() {
168+ await Promise.all([load(false), catalog.refresh(), refreshProjects()])
169+ }
170+ function adoptState(next: ScopeState) {
171+ state.value = next
172+ config.value = next.config ? cloneConfig(next.config) : null
173+ if (kind === 'project') refreshProjects()
174+ catalog.refresh()
175+ }
176+
177+ /** Home skills and MCP servers by name, for the inherited section and to recognise "hide global" actions. */
178+ const targets = computed<AgentId[]>(() => config.value?.targets ?? [])
179+
180+ return {
181+ kind, scopeId, state, config, loading, error, saving, inventory, statuses, plan, project, folderMissing, title, rootPath,
182+ load, rescan, saveConfig, edit, flush,
183+ resolutions, decided, resolutionFor, setResolution, resolutionBody,
184+ pending, conflictActions, openConflicts, targets,
185+ selectedItem, itemOpen, reviewOpen, importItems, importOpen, moveItems, moveOpen,
186+ openItem, askImport, askMove, openReview, afterCatalogChange, adoptState
187+ }
188+}
189+
190+export type ScopeContext = ReturnType<typeof createScope>
191+
192+const SCOPE_KEY: InjectionKey<ScopeContext> = Symbol('skilly-scope')
193+
194+export function provideScope(scope: ScopeContext) {
195+ provide(SCOPE_KEY, scope)
196+}
197+
198+export function useScopeContext(): ScopeContext {
199+ const scope = inject(SCOPE_KEY)
200+ if (!scope) throw new Error('useScopeContext() must be used inside a ScopePage')
201+ return scope
202+}
new file mode 100644
@@ -0,0 +1,202 @@
1+import type { AgentId, ConflictResolution, InventoryItem, ScopeConfig, ScopeState, SyncPlan } from '@skilly/core'
2+import type { InjectionKey } from 'vue'
3+
4+/**
5+ * State of one scope page (Home or a project): `GET /api/scopes/:scope/state`, the local editable copy of the
6+ * config (saved debounced with `PUT .../config`), the staged conflict resolutions, and the overlays the page owns
7+ * (item drawer, review drawer, import and remove-from-global modals).
8+ *
9+ * `ScopePage` creates it and provides it; the scope components read it with `useScopeContext()`.
10+ * Nothing is written to disk by this composable except `skilly.json` / `home.json` (the config); files change on Apply only.
11+ */
12+export function createScope(kind: 'home' | 'project', projectId: () => string | undefined) {
13+ const { request } = useApi()
14+ const { projects, refresh: refreshProjects } = useProjects()
15+ const catalog = useCatalog()
16+ const toast = useToast()
17+
18+ const scopeId = computed(() => (kind === 'home' ? 'home' : projectId() ?? ''))
19+ const state = ref<ScopeState | null>(null)
20+ const config = ref<ScopeConfig | null>(null)
21+ const loading = ref(true)
22+ const error = ref<string | null>(null)
23+ const saving = ref(false)
24+
25+ const inventory = computed(() => state.value?.inventory ?? null)
26+ const statuses = computed(() => state.value?.statuses ?? {})
27+ const plan = computed<SyncPlan | null>(() => state.value?.plan ?? null)
28+ const project = computed(() => (kind === 'project' ? projects.value.find(p => p.id === scopeId.value) : undefined))
29+ const folderMissing = computed(() => kind === 'project' && !!project.value && !project.value.exists)
30+ const title = computed(() => (kind === 'home' ? 'Home' : project.value?.name ?? scopeId.value))
31+ const rootPath = computed(() => inventory.value?.root ?? project.value?.path ?? '')
32+
33+ async function load(showLoading = true) {
34+ if (showLoading) loading.value = true
35+ try {
36+ state.value = await request<ScopeState>(`/api/scopes/${encodeURIComponent(scopeId.value)}/state`, { silent: true })
37+ config.value = state.value.config ? cloneConfig(state.value.config) : null
38+ error.value = null
39+ } catch (e) {
40+ error.value = (e as Error).message
41+ // The folder may have disappeared: refresh the summaries so `exists` is up to date.
42+ if (kind === 'project') await refreshProjects()
43+ } finally {
44+ loading.value = false
45+ }
46+ }
47+
48+ async function rescan() {
49+ await load(false)
50+ if (!error.value) toast.add({ title: 'Rescan complete', color: 'success', icon: 'i-lucide-check' })
51+ }
52+
53+ /* ---------------------------------------------------------------- config autosave */
54+
55+ let timer: ReturnType<typeof setTimeout> | undefined
56+ let seq = 0
57+ let adopted = 0
58+ let inflight: Promise<void> = Promise.resolve()
59+
60+ async function runSave(mine: number) {
61+ const body = config.value
62+ if (!body) return
63+ try {
64+ const res = await request<ScopeState>(`/api/scopes/${encodeURIComponent(scopeId.value)}/config`, { method: 'PUT', body, silent: true })
65+ // Ignore answers that arrive out of order.
66+ if (mine >= adopted) {
67+ adopted = mine
68+ state.value = res
69+ // Only adopt the server copy when no newer edit happened meanwhile.
70+ if (mine === seq) config.value = res.config ? cloneConfig(res.config) : body
71+ }
72+ } catch (e) {
73+ toast.add({ title: 'Could not save the configuration', description: (e as Error).message, color: 'error', icon: 'i-lucide-circle-alert' })
74+ if (mine === seq) await load(false)
75+ } finally {
76+ if (mine === seq) saving.value = false
77+ }
78+ }
79+
80+ /** Replaces the config: shown at once, saved in the background (debounced unless `immediate`). */
81+ function saveConfig(next: ScopeConfig, immediate = false) {
82+ config.value = next
83+ const mine = ++seq
84+ saving.value = true
85+ clearTimeout(timer)
86+ timer = undefined
87+ if (immediate) inflight = runSave(mine)
88+ else timer = setTimeout(() => { timer = undefined; inflight = runSave(mine) }, 400)
89+ }
90+
91+ /** Runs a pending save now and waits for it (before opening the review). */
92+ async function flush() {
93+ if (timer) {
94+ clearTimeout(timer)
95+ timer = undefined
96+ inflight = runSave(seq)
97+ }
98+ await inflight
99+ }
100+
101+ /** Edits a copy of the config and saves it. */
102+ function edit(fn: (c: ScopeConfig) => void) {
103+ if (!config.value) return
104+ const c = cloneConfig(config.value)
105+ fn(c)
106+ saveConfig(c)
107+ }
108+
109+ onBeforeUnmount(() => clearTimeout(timer))
110+
111+ /* ---------------------------------------------------------------- conflict resolutions (staged) */
112+
113+ const resolutions = ref<Record<string, ConflictResolution>>({})
114+ /** Actions for which the user explicitly chose a resolution (the default keep-local is not a decision). */
115+ const decided = ref<Set<string>>(new Set())
116+
117+ function resolutionFor(actionId: string): ConflictResolution {
118+ return resolutions.value[actionId] ?? 'keep-local'
119+ }
120+ function setResolution(actionId: string, r: ConflictResolution) {
121+ resolutions.value = { ...resolutions.value, [actionId]: r }
122+ decided.value = new Set([...decided.value, actionId])
123+ }
124+ /** Resolutions sent to Apply: one per conflict of the plan, keep-local unless chosen otherwise. */
125+ function resolutionBody(): Record<string, ConflictResolution> {
126+ return Object.fromEntries((plan.value?.actions ?? []).filter(a => a.kind === 'conflict').map(a => [a.id, resolutionFor(a.id)]))
127+ }
128+ watch(plan, (p) => {
129+ const ids = new Set((p?.actions ?? []).map(a => a.id))
130+ resolutions.value = Object.fromEntries(Object.entries(resolutions.value).filter(([id]) => ids.has(id)))
131+ decided.value = new Set([...decided.value].filter(id => ids.has(id)))
132+ })
133+
134+ const pending = computed(() => (plan.value?.actions ?? []).filter(isPending))
135+ const conflictActions = computed(() => pending.value.filter(a => a.kind === 'conflict'))
136+ /** Conflicts still waiting for a decision. */
137+ const openConflicts = computed(() => conflictActions.value.filter(a => !decided.value.has(a.id)))
138+
139+ /* ---------------------------------------------------------------- overlays owned by the page */
140+
141+ const selectedItem = ref<InventoryItem | null>(null)
142+ const itemOpen = ref(false)
143+ const reviewOpen = ref(false)
144+ const importItems = ref<InventoryItem[]>([])
145+ const importOpen = ref(false)
146+ const moveItems = ref<InventoryItem[]>([])
147+ const moveOpen = ref(false)
148+
149+ function openItem(item: InventoryItem) {
150+ selectedItem.value = item
151+ itemOpen.value = true
152+ }
153+ function askImport(items: InventoryItem[]) {
154+ importItems.value = items
155+ importOpen.value = true
156+ }
157+ function askMove(items: InventoryItem[]) {
158+ moveItems.value = items
159+ moveOpen.value = true
160+ }
161+ async function openReview() {
162+ await flush()
163+ reviewOpen.value = true
164+ }
165+
166+ /** After an import / move: refresh this scope, the catalog and the project summaries. */
167+ async function afterCatalogChange() {
168+ await Promise.all([load(false), catalog.refresh(), refreshProjects()])
169+ }
170+ function adoptState(next: ScopeState) {
171+ state.value = next
172+ config.value = next.config ? cloneConfig(next.config) : null
173+ if (kind === 'project') refreshProjects()
174+ catalog.refresh()
175+ }
176+
177+ /** Home skills and MCP servers by name, for the inherited section and to recognise "hide global" actions. */
178+ const targets = computed<AgentId[]>(() => config.value?.targets ?? [])
179+
180+ return {
181+ kind, scopeId, state, config, loading, error, saving, inventory, statuses, plan, project, folderMissing, title, rootPath,
182+ load, rescan, saveConfig, edit, flush,
183+ resolutions, decided, resolutionFor, setResolution, resolutionBody,
184+ pending, conflictActions, openConflicts, targets,
185+ selectedItem, itemOpen, reviewOpen, importItems, importOpen, moveItems, moveOpen,
186+ openItem, askImport, askMove, openReview, afterCatalogChange, adoptState
187+ }
188+}
189+
190+export type ScopeContext = ReturnType<typeof createScope>
191+
192+const SCOPE_KEY: InjectionKey<ScopeContext> = Symbol('skilly-scope')
193+
194+export function provideScope(scope: ScopeContext) {
195+ provide(SCOPE_KEY, scope)
196+}
197+
198+export function useScopeContext(): ScopeContext {
199+ const scope = inject(SCOPE_KEY)
200+ if (!scope) throw new Error('useScopeContext() must be used inside a ScopePage')
201+ return scope
202+}
modified packages/ui/app/layouts/default.vue +0 -2
@@ -23,8 +23,6 @@ onMounted(() => {
2323 </main>
2424
2525 <AddProjectModal v-model:open="addOpen" />
26- <!-- WP3: remove together with CatalogGitDialog once /sync replaces it. -->
27- <CatalogGitDialog />
2826 <AppSwitcher />
2927 </div>
3028 </template>
@@ -23,8 +23,6 @@ onMounted(() => {
23 </main>23 </main>
24 24
25 <AddProjectModal v-model:open="addOpen" />25 <AddProjectModal v-model:open="addOpen" />
26- <!-- WP3: remove together with CatalogGitDialog once /sync replaces it. -->
27- <CatalogGitDialog />
28 <AppSwitcher />26 <AppSwitcher />
29 </div>27 </div>
30 </template>28 </template>
modified packages/ui/app/pages/catalog/[type]/[name].vue +2 -3
@@ -2,10 +2,9 @@
22 import type { CatalogItemType } from '@skilly/core'
33
44 const route = useRoute()
5-const type = computed(() => String(route.params.type) as CatalogItemType)
6-const name = computed(() => String(route.params.name))
5+const selected = computed(() => ({ type: String(route.params.type) as CatalogItemType, name: String(route.params.name) }))
76 </script>
87
98 <template>
10- <CatalogItemEditor :key="`${type}/${name}`" :type="type" :name="name" />
9+ <CatalogBrowser :selected="selected" />
1110 </template>
@@ -2,10 +2,9 @@
2 import type { CatalogItemType } from '@skilly/core'2 import type { CatalogItemType } from '@skilly/core'
3 3
4 const route = useRoute()4 const route = useRoute()
5-const type = computed(() => String(route.params.type) as CatalogItemType)5+const selected = computed(() => ({ type: String(route.params.type) as CatalogItemType, name: String(route.params.name) }))
6-const name = computed(() => String(route.params.name))
7 </script>6 </script>
8 7
9 <template>8 <template>
10- <CatalogItemEditor :key="`${type}/${name}`" :type="type" :name="name" />9+ <CatalogBrowser :selected="selected" />
11 </template>10 </template>
modified packages/ui/app/pages/catalog/index.vue +1 -145
@@ -1,147 +1,3 @@
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-
511 <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>
2+ <CatalogBrowser />
1473 </template>
@@ -1,147 +1,3 @@
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>1 <template>
52- <UDashboardPanel id="catalog">2+ <CatalogBrowser />
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>3 </template>
modified packages/ui/app/pages/catalog/new.vue +1 -1
@@ -6,5 +6,5 @@ const type = computed<CatalogItemType>(() => (CAT_TYPES.includes(route.query.typ
66 </script>
77
88 <template>
9- <CatalogItemEditor :key="type" :type="type" />
9+ <CatalogBrowser :creating="type" />
1010 </template>
@@ -6,5 +6,5 @@ const type = computed<CatalogItemType>(() => (CAT_TYPES.includes(route.query.typ
6 </script>6 </script>
7 7
8 <template>8 <template>
9- <CatalogItemEditor :key="type" :type="type" />9+ <CatalogBrowser :creating="type" />
10 </template>10 </template>
modified packages/ui/app/pages/catalog/presets/index.vue +53 -67
@@ -1,80 +1,66 @@
11 <script setup lang="ts">
2-import type { CatalogItemType } from '@skilly/core'
2+import type { CatalogItemType, Preset } from '@skilly/core'
33
44 const { data, loading, error, refresh, exists, presets, presetUsedBy } = useCatalog()
5-const names = useScopeNames()
65
76 onMounted(refresh)
87
98 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'
9+const MAX_CHIPS = 12
10+
11+function chips(p: Preset) {
12+ return CAT_ORDER.flatMap(t => (p.items[t] ?? []).map(name => ({ type: t as CatalogItemType, name })))
13+}
14+const used = (name: string) => {
15+ const n = presetUsedBy(name).length
16+ return n ? `Enabled in ${n} project${n > 1 ? 's' : ''}` : 'Not enabled anywhere yet'
17+}
1318 </script>
1419
1520 <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="/catalog/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="/catalog/presets/new" size="sm" />
21+ <SkPage wide>
22+ <CatalogHeader tab="presets" />
23+
24+ <div v-if="loading && !data" class="grid gap-3 [grid-template-columns:repeat(auto-fill,minmax(300px,1fr))]">
25+ <USkeleton v-for="i in 4" :key="i" class="h-40 rounded-[10px]" />
26+ </div>
27+ <SkNote v-else-if="error && !data" tone="accent">
28+ <p class="m-0 font-medium">Could not load the catalog</p>
29+ <p class="mt-1 mb-0 text-xs">{{ error }}</p>
30+ <template #aside><SkButton size="sm" label="Retry" @click="refresh" /></template>
31+ </SkNote>
32+ <CatalogSetupCard v-else-if="data && !exists" />
33+
34+ <div v-else-if="data" class="grid gap-3 [grid-template-columns:repeat(auto-fill,minmax(300px,1fr))]">
35+ <NuxtLink
36+ to="/catalog/presets/new"
37+ class="flex min-h-[150px] flex-col items-center justify-center gap-1 rounded-[10px] border border-dashed border-sk-line p-[18px] text-sk-muted transition-colors hover:border-sk-faint hover:text-sk-text"
38+ >
39+ <span class="text-xl leading-none" aria-hidden="true">+</span>
40+ <span class="font-medium">New preset</span>
41+ </NuxtLink>
42+
43+ <NuxtLink
44+ v-for="p in sorted"
45+ :key="p.name"
46+ :to="`/catalog/presets/${encodeURIComponent(p.name)}`"
47+ class="flex flex-col gap-3 rounded-[10px] bg-sk-panel p-[18px] text-sk-text shadow-sk-ring transition-shadow hover:shadow-sk-ring-hover"
48+ >
49+ <div class="flex flex-col gap-[3px]">
50+ <span class="text-sm font-semibold">{{ p.name }}</span>
51+ <span v-if="p.description" class="text-pretty text-sk-muted">{{ p.description }}</span>
3652 </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(`/catalog/presets/${encodeURIComponent(p.name)}`)" @keydown.enter="navigateTo(`/catalog/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>
53+ <span v-if="p.includes.length" class="text-xs text-sk-muted">
54+ Includes preset
55+ <template v-for="(inc, i) in p.includes" :key="inc"><span class="font-medium text-sk-text">{{ inc }}</span><template v-if="i < p.includes.length - 1">, </template></template>
56+ </span>
57+ <div class="flex flex-wrap gap-1">
58+ <SkItemChip v-for="c in chips(p).slice(0, MAX_CHIPS)" :key="`${c.type}/${c.name}`" :name="c.name" :type="c.type" />
59+ <span v-if="chips(p).length > MAX_CHIPS" class="px-1 py-[3px] text-xs text-sk-faint">+{{ chips(p).length - MAX_CHIPS }} more</span>
60+ <span v-if="!chips(p).length" class="text-xs text-sk-faint">{{ p.includes.length ? 'Only the included presets' : 'No items yet' }}</span>
7661 </div>
77- </div>
78- </template>
79- </UDashboardPanel>
62+ <span class="mt-auto text-xs text-sk-faint">{{ used(p.name) }}</span>
63+ </NuxtLink>
64+ </div>
65+ </SkPage>
8066 </template>
@@ -1,80 +1,66 @@
1 <script setup lang="ts">1 <script setup lang="ts">
2-import type { CatalogItemType } from '@skilly/core'2+import type { CatalogItemType, Preset } from '@skilly/core'
3 3
4 const { data, loading, error, refresh, exists, presets, presetUsedBy } = useCatalog()4 const { data, loading, error, refresh, exists, presets, presetUsedBy } = useCatalog()
5-const names = useScopeNames()
6 5
7 onMounted(refresh)6 onMounted(refresh)
8 7
9 const sorted = computed(() => [...presets.value].sort((a, b) => a.name.localeCompare(b.name)))8 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 ?? 09+const MAX_CHIPS = 12
11-const total = (p: (typeof presets.value)[number]) => CAT_TYPES.reduce((n, t) => n + count(p, t), 0)10+
12-const TH = 'px-3 py-2 text-left text-xs font-medium uppercase tracking-wide text-muted'11+function chips(p: Preset) {
12+ return CAT_ORDER.flatMap(t => (p.items[t] ?? []).map(name => ({ type: t as CatalogItemType, name })))
13+}
14+const used = (name: string) => {
15+ const n = presetUsedBy(name).length
16+ return n ? `Enabled in ${n} project${n > 1 ? 's' : ''}` : 'Not enabled anywhere yet'
17+}
13 </script>18 </script>
14 19
15 <template>20 <template>
16- <UDashboardPanel id="presets">21+ <SkPage wide>
17- <template #header>22+ <CatalogHeader tab="presets" />
18- <UDashboardNavbar title="Presets" icon="i-lucide-layers">23+
19- <template #right>24+ <div v-if="loading && !data" class="grid gap-3 [grid-template-columns:repeat(auto-fill,minmax(300px,1fr))]">
20- <UButton v-if="exists" label="New preset" icon="i-lucide-plus" to="/catalog/presets/new" />25+ <USkeleton v-for="i in 4" :key="i" class="h-40 rounded-[10px]" />
21- </template>26+ </div>
22- </UDashboardNavbar>27+ <SkNote v-else-if="error && !data" tone="accent">
23- </template>28+ <p class="m-0 font-medium">Could not load the catalog</p>
24- <template #body>29+ <p class="mt-1 mb-0 text-xs">{{ error }}</p>
25- <div v-if="loading && !data" class="space-y-3">30+ <template #aside><SkButton size="sm" label="Retry" @click="refresh" /></template>
26- <USkeleton v-for="i in 4" :key="i" class="h-12 w-full" />31+ </SkNote>
27- </div>32+ <CatalogSetupCard v-else-if="data && !exists" />
28- <UAlert v-else-if="error && !data" color="error" variant="subtle" icon="i-lucide-circle-alert" title="Could not load the catalog" :description="error" />33+
29- <CatalogSetupCard v-else-if="data && !exists" />34+ <div v-else-if="data" class="grid gap-3 [grid-template-columns:repeat(auto-fill,minmax(300px,1fr))]">
30- <div v-else class="space-y-4">35+ <NuxtLink
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>36+ to="/catalog/presets/new"
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">37+ class="flex min-h-[150px] flex-col items-center justify-center gap-1 rounded-[10px] border border-dashed border-sk-line p-[18px] text-sk-muted transition-colors hover:border-sk-faint hover:text-sk-text"
33- <UIcon name="i-lucide-layers" class="size-8 text-dimmed" />38+ >
34- <p class="text-sm text-muted">No preset yet.</p>39+ <span class="text-xl leading-none" aria-hidden="true">+</span>
35- <UButton label="Create a preset" icon="i-lucide-plus" to="/catalog/presets/new" size="sm" />40+ <span class="font-medium">New preset</span>
41+ </NuxtLink>
42+
43+ <NuxtLink
44+ v-for="p in sorted"
45+ :key="p.name"
46+ :to="`/catalog/presets/${encodeURIComponent(p.name)}`"
47+ class="flex flex-col gap-3 rounded-[10px] bg-sk-panel p-[18px] text-sk-text shadow-sk-ring transition-shadow hover:shadow-sk-ring-hover"
48+ >
49+ <div class="flex flex-col gap-[3px]">
50+ <span class="text-sm font-semibold">{{ p.name }}</span>
51+ <span v-if="p.description" class="text-pretty text-sk-muted">{{ p.description }}</span>
36 </div>52 </div>
37- <div v-else class="overflow-x-auto rounded-xl border border-default">53+ <span v-if="p.includes.length" class="text-xs text-sk-muted">
38- <table class="w-full min-w-[720px] table-fixed text-sm">54+ Includes preset
39- <thead class="border-b border-default bg-elevated/40">55+ <template v-for="(inc, i) in p.includes" :key="inc"><span class="font-medium text-sk-text">{{ inc }}</span><template v-if="i < p.includes.length - 1">, </template></template>
40- <tr>56+ </span>
41- <th :class="TH" class="w-[30%]">Name</th>57+ <div class="flex flex-wrap gap-1">
42- <th :class="TH">Items</th>58+ <SkItemChip v-for="c in chips(p).slice(0, MAX_CHIPS)" :key="`${c.type}/${c.name}`" :name="c.name" :type="c.type" />
43- <th :class="TH" class="w-40">Includes</th>59+ <span v-if="chips(p).length > MAX_CHIPS" class="px-1 py-[3px] text-xs text-sk-faint">+{{ chips(p).length - MAX_CHIPS }} more</span>
44- <th :class="TH" class="w-28">Used by</th>60+ <span v-if="!chips(p).length" class="text-xs text-sk-faint">{{ p.includes.length ? 'Only the included presets' : 'No items yet' }}</span>
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(`/catalog/presets/${encodeURIComponent(p.name)}`)" @keydown.enter="navigateTo(`/catalog/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>61 </div>
77- </div>62+ <span class="mt-auto text-xs text-sk-faint">{{ used(p.name) }}</span>
78- </template>63+ </NuxtLink>
79- </UDashboardPanel>64+ </div>
65+ </SkPage>
80 </template>66 </template>
modified packages/ui/app/pages/home.vue +1 -1
@@ -1,3 +1,3 @@
11 <template>
2- <ScopeView kind="home" />
2+ <ScopePage kind="home" />
33 </template>
@@ -1,3 +1,3 @@
1 <template>1 <template>
2- <ScopeView kind="home" />2+ <ScopePage kind="home" />
3 </template>3 </template>
modified packages/ui/app/pages/index.vue +14 -3
@@ -1,7 +1,18 @@
11 <script setup lang="ts">
2-// The dashboard is gone: Projects is the landing page (brief section 5).
3-// WP3: redirect to /onboarding on first run (no catalog and no project yet) from here.
4-definePageMeta({ redirect: '/projects' })
2+/**
3+ * Landing: Projects, or the onboarding on first run (no catalog and no project yet), unless the user skipped or
4+ * finished it once (`skilly-onboarding-done`).
5+ */
6+const { refresh: refreshProjects } = useProjects()
7+const { refresh: refreshCatalog } = useCatalog()
8+
9+await Promise.all([refreshProjects(), refreshCatalog()])
10+const catalogExists = useCatalog().exists.value
11+const hasProjects = useProjects().projects.value.length > 0
12+let done = false
13+try { done = !!localStorage.getItem('skilly-onboarding-done') } catch { /* private mode */ }
14+
15+await navigateTo(!catalogExists && !hasProjects && !done ? '/onboarding' : '/projects', { replace: true })
516 </script>
617
718 <template>
@@ -1,7 +1,18 @@
1 <script setup lang="ts">1 <script setup lang="ts">
2-// The dashboard is gone: Projects is the landing page (brief section 5).2+/**
3-// WP3: redirect to /onboarding on first run (no catalog and no project yet) from here.3+ * Landing: Projects, or the onboarding on first run (no catalog and no project yet), unless the user skipped or
4-definePageMeta({ redirect: '/projects' })4+ * finished it once (`skilly-onboarding-done`).
5+ */
6+const { refresh: refreshProjects } = useProjects()
7+const { refresh: refreshCatalog } = useCatalog()
8+
9+await Promise.all([refreshProjects(), refreshCatalog()])
10+const catalogExists = useCatalog().exists.value
11+const hasProjects = useProjects().projects.value.length > 0
12+let done = false
13+try { done = !!localStorage.getItem('skilly-onboarding-done') } catch { /* private mode */ }
14+
15+await navigateTo(!catalogExists && !hasProjects && !done ? '/onboarding' : '/projects', { replace: true })
5 </script>16 </script>
6 17
7 <template>18 <template>
modified packages/ui/app/pages/marketplaces/index.vue +283 -175
@@ -1,9 +1,15 @@
11 <script setup lang="ts">
22 import type { ImportResult, MarketplaceSkill, MarketplaceSkillDetail, MarketplaceSource } from '@skilly/core'
33
4+/**
5+ * Marketplaces (brief 3.1): two panes like the Catalog. Left: sources (built-in + yours). Right: the skills of the
6+ * selected source, grouped by plugin for Claude marketplaces, with multi-select import and a preview drawer.
7+ */
48 const { request } = useApi()
59 const catalog = useCatalog()
610 const toast = useToast()
11+const route = useRoute()
12+const router = useRouter()
713
814 const sources = ref<MarketplaceSource[]>([])
915 const sourcesLoading = ref(true)
@@ -16,16 +22,30 @@ const skillsLoading = ref(false)
1622 const skillsError = ref<string | null>(null)
1723 const refreshing = ref(false)
1824 const search = ref('')
25+const onlyNew = ref<'all' | 'new'>('all')
1926 const picked = ref<Set<string>>(new Set())
2027 const LIMIT = 300
2128 const showAll = ref(false)
2229
30+/** Seconds since a fetch started, so a slow clone reads as "working" and not as "stuck". */
31+const elapsed = ref(0)
32+let timer: ReturnType<typeof setInterval> | undefined
33+const fetching = computed(() => refreshing.value || skillsLoading.value)
34+watch(fetching, (on) => {
35+ clearInterval(timer)
36+ elapsed.value = 0
37+ if (on) timer = setInterval(() => { elapsed.value++ }, 1000)
38+})
39+onBeforeUnmount(() => clearInterval(timer))
40+
2341 async function loadSources() {
2442 sourcesLoading.value = true
2543 try {
2644 sources.value = await request<MarketplaceSource[]>('/api/marketplaces', { silent: true })
2745 sourcesError.value = null
28- if (!selectedId.value && sources.value.length) await select(sources.value[0]!.id)
46+ const wanted = typeof route.query.source === 'string' ? route.query.source : null
47+ const initial = sources.value.find(s => s.id === (selectedId.value ?? wanted)) ?? sources.value[0]
48+ if (initial && initial.id !== selectedId.value) await select(initial.id)
2949 } catch (e) {
3050 sourcesError.value = (e as Error).message
3151 } finally {
@@ -35,15 +55,19 @@ async function loadSources() {
3555
3656 async function loadSkills() {
3757 if (!selectedId.value) return
58+ const id = selectedId.value
3859 skillsLoading.value = true
3960 skillsError.value = null
4061 try {
41- skills.value = await request<MarketplaceSkill[]>(`/api/marketplaces/${encodeURIComponent(selectedId.value)}/skills`, { silent: true })
62+ const list = await request<MarketplaceSkill[]>(`/api/marketplaces/${encodeURIComponent(id)}/skills`, { silent: true })
63+ if (id === selectedId.value) skills.value = list
4264 } catch (e) {
43- skills.value = []
44- skillsError.value = (e as Error).message
65+ if (id === selectedId.value) {
66+ skills.value = []
67+ skillsError.value = (e as Error).message
68+ }
4569 } finally {
46- skillsLoading.value = false
70+ if (id === selectedId.value) skillsLoading.value = false
4771 }
4872 }
4973
@@ -51,18 +75,33 @@ async function select(id: string) {
5175 selectedId.value = id
5276 picked.value = new Set()
5377 search.value = ''
78+ onlyNew.value = 'all'
5479 showAll.value = false
80+ skills.value = []
81+ if (route.query.source !== id) router.replace({ query: { ...route.query, source: id } })
5582 await loadSkills()
83+ // The listing may have fetched the source for the first time: pick up its "fetched" date.
84+ if (!skillsError.value) await reloadSource()
85+}
86+
87+async function reloadSource() {
88+ try {
89+ const list = await request<MarketplaceSource[]>('/api/marketplaces', { silent: true })
90+ sources.value = list
91+ if (!list.some(s => s.id === selectedId.value)) selectedId.value = null
92+ } catch { /* the list on screen stays valid */ }
5693 }
5794
5895 async function refreshSource() {
59- if (!selected.value) return
96+ if (!selected.value || refreshing.value) return
97+ const id = selected.value.id
6098 refreshing.value = true
6199 try {
62- const updated = await request<MarketplaceSource>(`/api/marketplaces/${encodeURIComponent(selected.value.id)}/refresh`, { method: 'POST', silent: true })
100+ const updated = await request<MarketplaceSource>(`/api/marketplaces/${encodeURIComponent(id)}/refresh`, { method: 'POST', silent: true })
63101 sources.value = sources.value.map(s => (s.id === updated.id ? updated : s))
102+ skillsLoading.value = true
64103 await loadSkills()
65- toast.add({ title: 'Source refreshed', description: updated.name, icon: 'i-lucide-check', color: 'success' })
104+ toast.add({ title: 'Source refreshed', description: `${updated.name} · ${skills.value.length} skills`, icon: 'i-lucide-check', color: 'success' })
66105 } catch (e) {
67106 toast.add({ title: 'Refresh failed', description: (e as Error).message, color: 'error', icon: 'i-lucide-circle-alert' })
68107 } finally {
@@ -79,7 +118,10 @@ onMounted(() => {
79118
80119 const filtered = computed(() => {
81120 const q = search.value.trim().toLowerCase()
82- return q ? skills.value.filter(s => `${s.name} ${s.description ?? ''} ${s.plugin ?? ''}`.toLowerCase().includes(q)) : skills.value
121+ return skills.value.filter(s =>
122+ (onlyNew.value === 'all' || !s.inCatalog)
123+ && (!q || `${s.name} ${s.description ?? ''} ${s.plugin ?? ''}`.toLowerCase().includes(q))
124+ )
83125 })
84126 const shown = computed(() => (showAll.value ? filtered.value : filtered.value.slice(0, LIMIT)))
85127 const groups = computed(() => {
@@ -91,6 +133,7 @@ const groups = computed(() => {
91133 }
92134 return [...m.entries()].map(([plugin, rows]) => ({ plugin, rows }))
93135 })
136+const inCatalogCount = computed(() => skills.value.filter(s => s.inCatalog).length)
94137
95138 function toggle(path: string, on: boolean | 'indeterminate') {
96139 const s = new Set(picked.value)
@@ -108,14 +151,23 @@ const groupState = (rows: MarketplaceSkill[]) => {
108151 return n === 0 ? false : n === rows.length ? true : 'indeterminate' as const
109152 }
110153
154+const stateOf = (s: MarketplaceSkill) =>
155+ s.inCatalog === 'same'
156+ ? { tone: 'ok' as const, label: 'In catalog' }
157+ : s.inCatalog === 'different'
158+ ? { tone: 'accent' as const, label: 'In catalog · different' }
159+ : null
160+
111161 /* ------------------------------------------------ Import */
112162
113-const differing = computed(() => skills.value.filter(s => picked.value.has(s.path) && s.inCatalog === 'different'))
163+const toImport = ref<string[]>([])
164+const differing = computed(() => skills.value.filter(s => toImport.value.includes(s.path) && s.inCatalog === 'different'))
114165 const confirmOpen = ref(false)
115166 const importing = ref(false)
116167
117-function startImport() {
118- if (differing.value.length) confirmOpen.value = true
168+function startImport(paths: string[]) {
169+ toImport.value = paths
170+ if (skills.value.some(s => paths.includes(s.path) && s.inCatalog === 'different')) confirmOpen.value = true
119171 else doImport(false)
120172 }
121173
@@ -125,17 +177,19 @@ async function doImport(overwrite: boolean) {
125177 try {
126178 const res = await request<ImportResult>(`/api/marketplaces/${encodeURIComponent(selected.value.id)}/import`, {
127179 method: 'POST',
128- body: { paths: [...picked.value], overwrite },
180+ body: { paths: toImport.value, overwrite },
129181 silent: true
130182 })
131183 confirmOpen.value = false
184+ previewOpen.value = false
132185 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.',
186+ title: `${res.imported.length} skill${res.imported.length === 1 ? '' : 's'} imported`,
187+ description: res.skipped.length ? `${res.skipped.length} skipped: ${res.skipped.map(s => `${s.id} (${s.reason})`).join('; ')}` : 'Added to the catalog. Commit them from the Sync page.',
135188 icon: 'i-lucide-check',
136189 color: res.skipped.length ? 'warning' : 'success'
137190 })
138- picked.value = new Set()
191+ const done = new Set(toImport.value)
192+ picked.value = new Set([...picked.value].filter(p => !done.has(p) || res.skipped.some(s => s.id === p)))
139193 await Promise.all([loadSkills(), catalog.refresh()])
140194 } catch (e) {
141195 toast.add({ title: 'Import failed', description: (e as Error).message, color: 'error', icon: 'i-lucide-circle-alert' })
@@ -150,10 +204,6 @@ const addOpen = ref(false)
150204 const adding = ref(false)
151205 const addError = ref<string | null>(null)
152206 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-]
157207
158208 async function addSource() {
159209 adding.value = true
@@ -166,7 +216,7 @@ async function addSource() {
166216 })
167217 addOpen.value = false
168218 Object.assign(addForm, { name: '', kind: 'git', url: '', ref: '' })
169- await loadSources()
219+ sources.value = await request<MarketplaceSource[]>('/api/marketplaces', { silent: true })
170220 await select(created.id)
171221 } catch (e) {
172222 addError.value = (e as Error).message
@@ -182,205 +232,263 @@ async function removeSource() {
182232 try {
183233 await request(`/api/marketplaces/${encodeURIComponent(s.id)}`, { method: 'DELETE' })
184234 removeTarget.value = null
185- if (selectedId.value === s.id) { selectedId.value = null; skills.value = [] }
186- await loadSources()
235+ const wasSelected = selectedId.value === s.id
236+ sources.value = await request<MarketplaceSource[]>('/api/marketplaces', { silent: true })
237+ if (wasSelected) {
238+ selectedId.value = null
239+ skills.value = []
240+ if (sources.value[0]) await select(sources.value[0].id)
241+ }
187242 } catch { /* toast shown */ }
188243 }
189244
190245 /* ------------------------------------------------ Preview */
191246
192247 const previewOpen = ref(false)
248+const previewSkill = ref<MarketplaceSkill | null>(null)
193249 const preview = ref<MarketplaceSkillDetail | null>(null)
194250 const previewLoading = ref(false)
195251 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))
252+const previewParts = computed(() => (preview.value ? splitFrontmatter(preview.value.content) : null))
198253
199254 async function openPreview(s: MarketplaceSkill) {
200255 previewOpen.value = true
256+ previewSkill.value = s
201257 preview.value = null
202258 previewError.value = null
203259 previewLoading.value = true
204260 try {
205- preview.value = await request<MarketplaceSkillDetail>(`/api/marketplaces/${encodeURIComponent(s.sourceId)}/skill`, { query: { path: s.path }, silent: true })
261+ const detail = await request<MarketplaceSkillDetail>(`/api/marketplaces/${encodeURIComponent(s.sourceId)}/skill`, { query: { path: s.path }, silent: true })
262+ if (previewSkill.value === s) preview.value = detail
206263 } catch (e) {
207- previewError.value = (e as Error).message
264+ if (previewSkill.value === s) previewError.value = (e as Error).message
208265 } finally {
209- previewLoading.value = false
266+ if (previewSkill.value === s) previewLoading.value = false
210267 }
211268 }
212269
213-const sinceFetched = (s: MarketplaceSource) => (s.lastFetchedAt ? `fetched ${formatDate(s.lastFetchedAt)}` : 'never fetched')
270+const KIND_LABEL = { git: 'Git repository', 'claude-marketplace': 'Claude marketplace' } as const
271+const sinceFetched = (s: MarketplaceSource) => (s.lastFetchedAt ? `fetched ${timeAgo(s.lastFetchedAt)}` : 'not fetched yet')
272+const plural = (n: number, w: string) => `${n} ${w}${n === 1 ? '' : 's'}`
214273 </script>
215274
216275 <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
276+ <SkPage wide>
277+ <SkPageHeader title="Marketplaces" subtitle="Skills from git repositories and Claude plugin marketplaces. Import them into your catalog." />
278+
279+ <SkPanel v-if="sourcesLoading && !sources.length" padded><USkeleton class="h-64 w-full" /></SkPanel>
280+ <SkNote v-else-if="sourcesError" tone="accent">
281+ <div class="flex items-center gap-3">
282+ <span class="flex-1">Could not load the sources: {{ sourcesError }}</span>
283+ <SkButton size="xs" label="Retry" @click="loadSources" />
284+ </div>
285+ </SkNote>
286+
287+ <div v-else class="grid items-start gap-4 lg:grid-cols-[280px_minmax(0,1fr)]">
288+ <!-- Sources -->
289+ <aside class="flex min-w-0 flex-col gap-2.5 lg:sticky lg:top-[72px]">
290+ <SkPanel>
291+ <SkRow
234292 v-for="s in sources"
235293 :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'"
294+ interactive
295+ :selected="s.id === selectedId"
296+ class="!gap-2 !py-2.5"
297+ role="button"
238298 tabindex="0"
299+ :aria-current="s.id === selectedId ? 'true' : undefined"
239300 @click="select(s.id)"
240301 @keydown.enter="select(s.id)"
241302 >
242- <UIcon :name="s.kind === 'git' ? 'i-lucide-git-fork' : 'i-lucide-puzzle'" class="mt-0.5 size-4 shrink-0 text-muted" />
243303 <div class="min-w-0 flex-1">
244304 <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" />
305+ <span class="truncate font-medium">{{ s.name }}</span>
306+ <SkTag v-if="s.builtIn">built-in</SkTag>
247307 </div>
248- <p class="truncate text-xs text-muted" :title="s.url">{{ s.url }}</p>
308+ <p class="m-0 truncate font-mono text-[11px] text-sk-faint" :title="s.url">{{ s.url }}</p>
249309 </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">
310+ <UTooltip v-if="!s.builtIn" text="Remove source">
311+ <SkButton size="xs" variant="ghost" icon="i-lucide-trash-2" :aria-label="`Remove ${s.name}`" @click.stop="removeTarget = s" />
312+ </UTooltip>
313+ </SkRow>
314+ </SkPanel>
315+ <SkButton label="Add source" icon="i-lucide-plus" @click="addOpen = true" />
316+ </aside>
317+
318+ <!-- Selected source -->
319+ <section v-if="selected" class="flex min-w-0 flex-col gap-3.5">
320+ <SkPanel padded class="flex flex-col gap-3.5">
256321 <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>
322+ <div class="min-w-0 flex-1">
323+ <h2 class="m-0 truncate text-[18px] leading-tight font-semibold tracking-[-0.01em]">{{ selected.name }}</h2>
324+ <p class="m-0 mt-0.5 text-sk-muted">
325+ {{ KIND_LABEL[selected.kind] }}<template v-if="selected.ref"> · <span class="font-mono text-xs">{{ selected.ref }}</span></template> · {{ sinceFetched(selected) }}
326+ </p>
260327 </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" />
328+ <SkButton icon="i-lucide-refresh-cw" label="Refresh" :loading="refreshing" :disabled="skillsLoading" @click="refreshSource" />
269329 </div>
270330
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>
331+ <SkNote v-if="fetching" tone="info">
332+ <p class="m-0 font-medium">{{ refreshing ? 'Fetching the repository…' : 'Loading the skills…' }} <span class="font-mono text-xs text-sk-faint">{{ elapsed }}s</span></p>
333+ <p class="m-0 text-xs text-sk-muted">The first load clones the repository, which can take a minute for large ones. You can keep using skilly meanwhile.</p>
334+ </SkNote>
335+
336+ <div class="flex flex-wrap items-center gap-2.5">
337+ <UInput v-model="search" icon="i-lucide-search" placeholder="Search name, plugin or description" aria-label="Search skills" class="min-w-0 flex-1 basis-64" />
338+ <SkSeg
339+ v-model="onlyNew"
340+ size="md"
341+ aria-label="Filter"
342+ :options="[{ value: 'all', label: 'All', count: skills.length }, { value: 'new', label: 'Not in catalog', count: skills.length - inCatalogCount }]"
343+ />
279344 </div>
345+ </SkPanel>
280346
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" />
347+ <SkNote v-if="skillsError && !skillsLoading" tone="accent">
348+ <div class="flex items-start gap-3">
349+ <div class="min-w-0 flex-1">
350+ <p class="m-0 font-semibold">Could not load the skills</p>
351+ <p class="m-0 font-mono text-xs break-words whitespace-pre-wrap">{{ skillsError }}</p>
305352 </div>
353+ <SkButton size="xs" label="Refresh source" :disabled="refreshing" @click="refreshSource" />
306354 </div>
307- </section>
308- </div>
355+ </SkNote>
309356
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>
357+ <SkPanel v-else-if="skillsLoading && !skills.length" padded class="flex flex-col gap-2">
358+ <USkeleton v-for="i in 5" :key="i" class="h-11 w-full" />
359+ </SkPanel>
360+
361+ <SkPanel v-else-if="!filtered.length">
362+ <SkEmpty :text="search || onlyNew === 'new' ? 'Nothing matches these filters.' : 'No skill found in this source. Try Refresh.'" />
363+ </SkPanel>
364+
365+ <SkTray v-else>
366+ <template v-for="g in groups" :key="g.plugin ?? 'all'">
367+ <div v-if="g.plugin" class="flex items-center gap-2 px-2.5 pt-2 pb-1">
368+ <UCheckbox :model-value="groupState(g.rows)" :aria-label="`Select every skill of ${g.plugin}`" @update:model-value="(v: boolean | 'indeterminate') => toggleGroup(g.rows, v)" />
369+ <SkSectionLabel :count="g.rows.length">{{ g.plugin }}</SkSectionLabel>
333370 </div>
371+ <SkRowCard
372+ v-for="s in g.rows"
373+ :key="s.path"
374+ cols="16px minmax(0,1fr) auto"
375+ interactive
376+ role="button"
377+ tabindex="0"
378+ @click="openPreview(s)"
379+ @keydown.enter.self="openPreview(s)"
380+ >
381+ <span class="flex" @click.stop>
382+ <UCheckbox :model-value="picked.has(s.path)" :aria-label="`Select ${s.name}`" @update:model-value="(v: boolean | 'indeterminate') => toggle(s.path, v)" />
383+ </span>
384+ <div class="min-w-0">
385+ <span class="block truncate font-medium">{{ s.name }}</span>
386+ <p v-if="s.description" class="m-0 line-clamp-2 text-xs text-pretty text-sk-muted">{{ s.description }}</p>
387+ <p class="m-0 truncate font-mono text-[11px] text-sk-faint" :title="s.path">{{ s.path }}</p>
388+ </div>
389+ <SkStatus v-if="stateOf(s)" :tone="stateOf(s)!.tone" :label="stateOf(s)!.label" muted />
390+ </SkRowCard>
391+ </template>
392+ <div v-if="filtered.length > shown.length" class="py-2 text-center">
393+ <SkButton size="sm" variant="ghost" :label="`Show all ${filtered.length}`" @click="showAll = true" />
334394 </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()" />
395+ </SkTray>
396+ </section>
397+ <SkPanel v-else padded><SkEmpty text="Select a source to browse its skills." /></SkPanel>
398+ </div>
399+
400+ <SkActionBar v-if="picked.size" :changes="picked.size" noun="selected" label="Import to catalog" :busy="importing" @review="startImport([...picked])" />
401+
402+ <!-- Preview -->
403+ <SkDrawer v-model:open="previewOpen" width="lg">
404+ <template #header>
405+ <div class="flex min-w-0 flex-1 flex-col gap-1">
406+ <div class="flex items-baseline gap-2">
407+ <h2 class="m-0 truncate text-[18px] leading-tight font-semibold tracking-[-0.01em]">{{ preview?.name ?? previewSkill?.name ?? 'Preview' }}</h2>
408+ <span class="font-mono text-[11px] text-sk-faint">Skill</span>
369409 </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" />
410+ <p v-if="preview?.description ?? previewSkill?.description" class="m-0 text-pretty text-sk-muted">{{ preview?.description ?? previewSkill?.description }}</p>
411+ </div>
412+ </template>
413+
414+ <div v-if="previewLoading" class="flex flex-col gap-2"><USkeleton class="h-4 w-1/3" /><USkeleton class="h-4 w-full" /><USkeleton class="h-4 w-5/6" /></div>
415+ <SkNote v-else-if="previewError" tone="accent">Could not load the preview: {{ previewError }}</SkNote>
416+ <div v-else-if="preview" class="flex flex-col gap-4">
417+ <SkMetaGrid label-width="100px">
418+ <SkMeta label="Source">{{ selected?.name }}</SkMeta>
419+ <SkMeta v-if="previewSkill?.plugin" label="Plugin" mono>{{ previewSkill.plugin }}</SkMeta>
420+ <SkMeta label="Path" mono>{{ preview.path }}</SkMeta>
421+ <SkMeta v-if="previewSkill && stateOf(previewSkill)" label="Catalog"><SkStatus :tone="stateOf(previewSkill)!.tone" :label="stateOf(previewSkill)!.label" /></SkMeta>
422+ <SkMeta v-for="(v, k) in previewParts?.frontmatter ?? {}" :key="k" :label="String(k)">{{ typeof v === 'string' ? v : JSON.stringify(v) }}</SkMeta>
423+ </SkMetaGrid>
424+ <MarkdownBody :source="previewParts?.body ?? ''" />
425+ <div v-if="preview.files.length" class="flex flex-col gap-2">
426+ <SkSectionLabel :count="preview.files.length">Files</SkSectionLabel>
427+ <SkTray class="max-h-52 overflow-auto">
428+ <SkRowCard v-for="f in preview.files" :key="f" class="truncate font-mono text-xs">{{ f }}</SkRowCard>
429+ </SkTray>
430+ </div>
431+ </div>
432+
433+ <template #footer>
434+ <span class="mr-auto text-xs text-sk-faint">Imports the whole skill folder.</span>
435+ <SkButton label="Close" @click="previewOpen = false" />
436+ <SkButton variant="primary" label="Import to catalog" :loading="importing" :disabled="!previewSkill || !!previewError" @click="previewSkill && startImport([previewSkill.path])" />
437+ </template>
438+ </SkDrawer>
439+
440+ <!-- Overwrite confirmation -->
441+ <SkModal v-model:open="confirmOpen" :title="`Overwrite ${plural(differing.length, 'catalog skill')}?`" width="sm">
442+ <div class="flex flex-col gap-3.5">
443+ <div class="flex items-start gap-2.5">
444+ <SkStepBadge>1</SkStepBadge>
445+ <div class="min-w-0">
446+ <p class="m-0 font-medium">These skills already exist with different content</p>
447+ <ul class="m-0 mt-1 list-none p-0 font-mono text-xs text-sk-muted">
448+ <li v-for="s in differing" :key="s.path" class="truncate">{{ s.name }}</li>
449+ </ul>
381450 </div>
382- </template>
383- </UModal>
384- </template>
385- </UDashboardPanel>
451+ </div>
452+ <div class="flex items-start gap-2.5">
453+ <SkStepBadge>2</SkStepBadge>
454+ <p class="m-0 text-pretty text-sk-muted">The catalog version is replaced by the marketplace version. Local edits are lost from the working folder; git history keeps them. Projects using the skill will show an update.</p>
455+ </div>
456+ </div>
457+ <template #footer>
458+ <SkButton label="Cancel" @click="confirmOpen = false" />
459+ <SkButton variant="primary" label="Overwrite and import" :loading="importing" @click="doImport(true)" />
460+ </template>
461+ </SkModal>
462+
463+ <!-- Add source -->
464+ <SkModal v-model:open="addOpen" title="Add a source" description="A git repository containing skills, or a Claude Code plugin marketplace." width="sm">
465+ <form id="add-source" class="flex flex-col gap-3.5" @submit.prevent="addSource">
466+ <SkNote v-if="addError" tone="accent">{{ addError }}</SkNote>
467+ <label class="flex flex-col gap-1.5"><SkSectionLabel>Name</SkSectionLabel><UInput v-model="addForm.name" placeholder="My skills" /></label>
468+ <div class="flex flex-col gap-1.5">
469+ <SkSectionLabel>Kind</SkSectionLabel>
470+ <SkSeg v-model="addForm.kind" size="md" aria-label="Kind" :options="[{ value: 'git', label: 'Git repository' }, { value: 'claude-marketplace', label: 'Claude marketplace' }]" />
471+ </div>
472+ <label class="flex flex-col gap-1.5"><SkSectionLabel>URL</SkSectionLabel><UInput v-model="addForm.url" placeholder="https://github.com/owner/repo.git" :ui="{ base: 'font-mono' }" /></label>
473+ <label class="flex flex-col gap-1.5">
474+ <SkSectionLabel>Ref</SkSectionLabel>
475+ <UInput v-model="addForm.ref" placeholder="main" :ui="{ base: 'font-mono' }" />
476+ <span class="text-xs text-sk-faint">Branch or tag. The default branch when empty.</span>
477+ </label>
478+ </form>
479+ <template #footer>
480+ <SkButton label="Cancel" @click="addOpen = false" />
481+ <SkButton type="submit" form="add-source" variant="primary" label="Add source" :loading="adding" :disabled="!addForm.name.trim() || !addForm.url.trim()" />
482+ </template>
483+ </SkModal>
484+
485+ <!-- Remove source -->
486+ <SkModal :open="!!removeTarget" title="Remove source" width="sm" @update:open="(v: boolean) => { if (!v) removeTarget = null }">
487+ <p class="m-0 text-pretty">Remove <strong>{{ removeTarget?.name }}</strong> from your sources? Skills already imported stay in the catalog.</p>
488+ <template #footer>
489+ <SkButton label="Cancel" @click="removeTarget = null" />
490+ <SkButton variant="primary" label="Remove source" @click="removeSource" />
491+ </template>
492+ </SkModal>
493+ </SkPage>
386494 </template>
@@ -1,9 +1,15 @@
1 <script setup lang="ts">1 <script setup lang="ts">
2 import type { ImportResult, MarketplaceSkill, MarketplaceSkillDetail, MarketplaceSource } from '@skilly/core'2 import type { ImportResult, MarketplaceSkill, MarketplaceSkillDetail, MarketplaceSource } from '@skilly/core'
3 3
4+/**
5+ * Marketplaces (brief 3.1): two panes like the Catalog. Left: sources (built-in + yours). Right: the skills of the
6+ * selected source, grouped by plugin for Claude marketplaces, with multi-select import and a preview drawer.
7+ */
4 const { request } = useApi()8 const { request } = useApi()
5 const catalog = useCatalog()9 const catalog = useCatalog()
6 const toast = useToast()10 const toast = useToast()
11+const route = useRoute()
12+const router = useRouter()
7 13
8 const sources = ref<MarketplaceSource[]>([])14 const sources = ref<MarketplaceSource[]>([])
9 const sourcesLoading = ref(true)15 const sourcesLoading = ref(true)
@@ -16,16 +22,30 @@ const skillsLoading = ref(false)
16 const skillsError = ref<string | null>(null)22 const skillsError = ref<string | null>(null)
17 const refreshing = ref(false)23 const refreshing = ref(false)
18 const search = ref('')24 const search = ref('')
25+const onlyNew = ref<'all' | 'new'>('all')
19 const picked = ref<Set<string>>(new Set())26 const picked = ref<Set<string>>(new Set())
20 const LIMIT = 30027 const LIMIT = 300
21 const showAll = ref(false)28 const showAll = ref(false)
22 29
30+/** Seconds since a fetch started, so a slow clone reads as "working" and not as "stuck". */
31+const elapsed = ref(0)
32+let timer: ReturnType<typeof setInterval> | undefined
33+const fetching = computed(() => refreshing.value || skillsLoading.value)
34+watch(fetching, (on) => {
35+ clearInterval(timer)
36+ elapsed.value = 0
37+ if (on) timer = setInterval(() => { elapsed.value++ }, 1000)
38+})
39+onBeforeUnmount(() => clearInterval(timer))
40+
23 async function loadSources() {41 async function loadSources() {
24 sourcesLoading.value = true42 sourcesLoading.value = true
25 try {43 try {
26 sources.value = await request<MarketplaceSource[]>('/api/marketplaces', { silent: true })44 sources.value = await request<MarketplaceSource[]>('/api/marketplaces', { silent: true })
27 sourcesError.value = null45 sourcesError.value = null
28- if (!selectedId.value && sources.value.length) await select(sources.value[0]!.id)46+ const wanted = typeof route.query.source === 'string' ? route.query.source : null
47+ const initial = sources.value.find(s => s.id === (selectedId.value ?? wanted)) ?? sources.value[0]
48+ if (initial && initial.id !== selectedId.value) await select(initial.id)
29 } catch (e) {49 } catch (e) {
30 sourcesError.value = (e as Error).message50 sourcesError.value = (e as Error).message
31 } finally {51 } finally {
@@ -35,15 +55,19 @@ async function loadSources() {
35 55
36 async function loadSkills() {56 async function loadSkills() {
37 if (!selectedId.value) return57 if (!selectedId.value) return
58+ const id = selectedId.value
38 skillsLoading.value = true59 skillsLoading.value = true
39 skillsError.value = null60 skillsError.value = null
40 try {61 try {
41- skills.value = await request<MarketplaceSkill[]>(`/api/marketplaces/${encodeURIComponent(selectedId.value)}/skills`, { silent: true })62+ const list = await request<MarketplaceSkill[]>(`/api/marketplaces/${encodeURIComponent(id)}/skills`, { silent: true })
63+ if (id === selectedId.value) skills.value = list
42 } catch (e) {64 } catch (e) {
43- skills.value = []65+ if (id === selectedId.value) {
44- skillsError.value = (e as Error).message66+ skills.value = []
67+ skillsError.value = (e as Error).message
68+ }
45 } finally {69 } finally {
46- skillsLoading.value = false70+ if (id === selectedId.value) skillsLoading.value = false
47 }71 }
48 }72 }
49 73
@@ -51,18 +75,33 @@ async function select(id: string) {
51 selectedId.value = id75 selectedId.value = id
52 picked.value = new Set()76 picked.value = new Set()
53 search.value = ''77 search.value = ''
78+ onlyNew.value = 'all'
54 showAll.value = false79 showAll.value = false
80+ skills.value = []
81+ if (route.query.source !== id) router.replace({ query: { ...route.query, source: id } })
55 await loadSkills()82 await loadSkills()
83+ // The listing may have fetched the source for the first time: pick up its "fetched" date.
84+ if (!skillsError.value) await reloadSource()
85+}
86+
87+async function reloadSource() {
88+ try {
89+ const list = await request<MarketplaceSource[]>('/api/marketplaces', { silent: true })
90+ sources.value = list
91+ if (!list.some(s => s.id === selectedId.value)) selectedId.value = null
92+ } catch { /* the list on screen stays valid */ }
56 }93 }
57 94
58 async function refreshSource() {95 async function refreshSource() {
59- if (!selected.value) return96+ if (!selected.value || refreshing.value) return
97+ const id = selected.value.id
60 refreshing.value = true98 refreshing.value = true
61 try {99 try {
62- const updated = await request<MarketplaceSource>(`/api/marketplaces/${encodeURIComponent(selected.value.id)}/refresh`, { method: 'POST', silent: true })100+ const updated = await request<MarketplaceSource>(`/api/marketplaces/${encodeURIComponent(id)}/refresh`, { method: 'POST', silent: true })
63 sources.value = sources.value.map(s => (s.id === updated.id ? updated : s))101 sources.value = sources.value.map(s => (s.id === updated.id ? updated : s))
102+ skillsLoading.value = true
64 await loadSkills()103 await loadSkills()
65- toast.add({ title: 'Source refreshed', description: updated.name, icon: 'i-lucide-check', color: 'success' })104+ toast.add({ title: 'Source refreshed', description: `${updated.name} · ${skills.value.length} skills`, icon: 'i-lucide-check', color: 'success' })
66 } catch (e) {105 } catch (e) {
67 toast.add({ title: 'Refresh failed', description: (e as Error).message, color: 'error', icon: 'i-lucide-circle-alert' })106 toast.add({ title: 'Refresh failed', description: (e as Error).message, color: 'error', icon: 'i-lucide-circle-alert' })
68 } finally {107 } finally {
@@ -79,7 +118,10 @@ onMounted(() => {
79 118
80 const filtered = computed(() => {119 const filtered = computed(() => {
81 const q = search.value.trim().toLowerCase()120 const q = search.value.trim().toLowerCase()
82- return q ? skills.value.filter(s => `${s.name} ${s.description ?? ''} ${s.plugin ?? ''}`.toLowerCase().includes(q)) : skills.value121+ return skills.value.filter(s =>
122+ (onlyNew.value === 'all' || !s.inCatalog)
123+ && (!q || `${s.name} ${s.description ?? ''} ${s.plugin ?? ''}`.toLowerCase().includes(q))
124+ )
83 })125 })
84 const shown = computed(() => (showAll.value ? filtered.value : filtered.value.slice(0, LIMIT)))126 const shown = computed(() => (showAll.value ? filtered.value : filtered.value.slice(0, LIMIT)))
85 const groups = computed(() => {127 const groups = computed(() => {
@@ -91,6 +133,7 @@ const groups = computed(() => {
91 }133 }
92 return [...m.entries()].map(([plugin, rows]) => ({ plugin, rows }))134 return [...m.entries()].map(([plugin, rows]) => ({ plugin, rows }))
93 })135 })
136+const inCatalogCount = computed(() => skills.value.filter(s => s.inCatalog).length)
94 137
95 function toggle(path: string, on: boolean | 'indeterminate') {138 function toggle(path: string, on: boolean | 'indeterminate') {
96 const s = new Set(picked.value)139 const s = new Set(picked.value)
@@ -108,14 +151,23 @@ const groupState = (rows: MarketplaceSkill[]) => {
108 return n === 0 ? false : n === rows.length ? true : 'indeterminate' as const151 return n === 0 ? false : n === rows.length ? true : 'indeterminate' as const
109 }152 }
110 153
154+const stateOf = (s: MarketplaceSkill) =>
155+ s.inCatalog === 'same'
156+ ? { tone: 'ok' as const, label: 'In catalog' }
157+ : s.inCatalog === 'different'
158+ ? { tone: 'accent' as const, label: 'In catalog · different' }
159+ : null
160+
111 /* ------------------------------------------------ Import */161 /* ------------------------------------------------ Import */
112 162
113-const differing = computed(() => skills.value.filter(s => picked.value.has(s.path) && s.inCatalog === 'different'))163+const toImport = ref<string[]>([])
164+const differing = computed(() => skills.value.filter(s => toImport.value.includes(s.path) && s.inCatalog === 'different'))
114 const confirmOpen = ref(false)165 const confirmOpen = ref(false)
115 const importing = ref(false)166 const importing = ref(false)
116 167
117-function startImport() {168+function startImport(paths: string[]) {
118- if (differing.value.length) confirmOpen.value = true169+ toImport.value = paths
170+ if (skills.value.some(s => paths.includes(s.path) && s.inCatalog === 'different')) confirmOpen.value = true
119 else doImport(false)171 else doImport(false)
120 }172 }
121 173
@@ -125,17 +177,19 @@ async function doImport(overwrite: boolean) {
125 try {177 try {
126 const res = await request<ImportResult>(`/api/marketplaces/${encodeURIComponent(selected.value.id)}/import`, {178 const res = await request<ImportResult>(`/api/marketplaces/${encodeURIComponent(selected.value.id)}/import`, {
127 method: 'POST',179 method: 'POST',
128- body: { paths: [...picked.value], overwrite },180+ body: { paths: toImport.value, overwrite },
129 silent: true181 silent: true
130 })182 })
131 confirmOpen.value = false183 confirmOpen.value = false
184+ previewOpen.value = false
132 toast.add({185 toast.add({
133- title: `${res.imported.length} imported`,186+ title: `${res.imported.length} skill${res.imported.length === 1 ? '' : 's'} imported`,
134- description: res.skipped.length ? `${res.skipped.length} skipped: ${res.skipped.map(s => `${s.id} (${s.reason})`).join('; ')}` : 'Added to the catalog.',187+ description: res.skipped.length ? `${res.skipped.length} skipped: ${res.skipped.map(s => `${s.id} (${s.reason})`).join('; ')}` : 'Added to the catalog. Commit them from the Sync page.',
135 icon: 'i-lucide-check',188 icon: 'i-lucide-check',
136 color: res.skipped.length ? 'warning' : 'success'189 color: res.skipped.length ? 'warning' : 'success'
137 })190 })
138- picked.value = new Set()191+ const done = new Set(toImport.value)
192+ picked.value = new Set([...picked.value].filter(p => !done.has(p) || res.skipped.some(s => s.id === p)))
139 await Promise.all([loadSkills(), catalog.refresh()])193 await Promise.all([loadSkills(), catalog.refresh()])
140 } catch (e) {194 } catch (e) {
141 toast.add({ title: 'Import failed', description: (e as Error).message, color: 'error', icon: 'i-lucide-circle-alert' })195 toast.add({ title: 'Import failed', description: (e as Error).message, color: 'error', icon: 'i-lucide-circle-alert' })
@@ -150,10 +204,6 @@ const addOpen = ref(false)
150 const adding = ref(false)204 const adding = ref(false)
151 const addError = ref<string | null>(null)205 const addError = ref<string | null>(null)
152 const addForm = reactive({ name: '', kind: 'git' as MarketplaceSource['kind'], url: '', ref: '' })206 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 207
158 async function addSource() {208 async function addSource() {
159 adding.value = true209 adding.value = true
@@ -166,7 +216,7 @@ async function addSource() {
166 })216 })
167 addOpen.value = false217 addOpen.value = false
168 Object.assign(addForm, { name: '', kind: 'git', url: '', ref: '' })218 Object.assign(addForm, { name: '', kind: 'git', url: '', ref: '' })
169- await loadSources()219+ sources.value = await request<MarketplaceSource[]>('/api/marketplaces', { silent: true })
170 await select(created.id)220 await select(created.id)
171 } catch (e) {221 } catch (e) {
172 addError.value = (e as Error).message222 addError.value = (e as Error).message
@@ -182,205 +232,263 @@ async function removeSource() {
182 try {232 try {
183 await request(`/api/marketplaces/${encodeURIComponent(s.id)}`, { method: 'DELETE' })233 await request(`/api/marketplaces/${encodeURIComponent(s.id)}`, { method: 'DELETE' })
184 removeTarget.value = null234 removeTarget.value = null
185- if (selectedId.value === s.id) { selectedId.value = null; skills.value = [] }235+ const wasSelected = selectedId.value === s.id
186- await loadSources()236+ sources.value = await request<MarketplaceSource[]>('/api/marketplaces', { silent: true })
237+ if (wasSelected) {
238+ selectedId.value = null
239+ skills.value = []
240+ if (sources.value[0]) await select(sources.value[0].id)
241+ }
187 } catch { /* toast shown */ }242 } catch { /* toast shown */ }
188 }243 }
189 244
190 /* ------------------------------------------------ Preview */245 /* ------------------------------------------------ Preview */
191 246
192 const previewOpen = ref(false)247 const previewOpen = ref(false)
248+const previewSkill = ref<MarketplaceSkill | null>(null)
193 const preview = ref<MarketplaceSkillDetail | null>(null)249 const preview = ref<MarketplaceSkillDetail | null>(null)
194 const previewLoading = ref(false)250 const previewLoading = ref(false)
195 const previewError = ref<string | null>(null)251 const previewError = ref<string | null>(null)
196-const previewHtml = computed(() => (preview.value ? renderMarkdown(splitFrontmatter(preview.value.content).body) : ''))252+const previewParts = computed(() => (preview.value ? splitFrontmatter(preview.value.content) : null))
197-const previewFm = computed(() => (preview.value ? splitFrontmatter(preview.value.content).frontmatter : null))
198 253
199 async function openPreview(s: MarketplaceSkill) {254 async function openPreview(s: MarketplaceSkill) {
200 previewOpen.value = true255 previewOpen.value = true
256+ previewSkill.value = s
201 preview.value = null257 preview.value = null
202 previewError.value = null258 previewError.value = null
203 previewLoading.value = true259 previewLoading.value = true
204 try {260 try {
205- preview.value = await request<MarketplaceSkillDetail>(`/api/marketplaces/${encodeURIComponent(s.sourceId)}/skill`, { query: { path: s.path }, silent: true })261+ const detail = await request<MarketplaceSkillDetail>(`/api/marketplaces/${encodeURIComponent(s.sourceId)}/skill`, { query: { path: s.path }, silent: true })
262+ if (previewSkill.value === s) preview.value = detail
206 } catch (e) {263 } catch (e) {
207- previewError.value = (e as Error).message264+ if (previewSkill.value === s) previewError.value = (e as Error).message
208 } finally {265 } finally {
209- previewLoading.value = false266+ if (previewSkill.value === s) previewLoading.value = false
210 }267 }
211 }268 }
212 269
213-const sinceFetched = (s: MarketplaceSource) => (s.lastFetchedAt ? `fetched ${formatDate(s.lastFetchedAt)}` : 'never fetched')270+const KIND_LABEL = { git: 'Git repository', 'claude-marketplace': 'Claude marketplace' } as const
271+const sinceFetched = (s: MarketplaceSource) => (s.lastFetchedAt ? `fetched ${timeAgo(s.lastFetchedAt)}` : 'not fetched yet')
272+const plural = (n: number, w: string) => `${n} ${w}${n === 1 ? '' : 's'}`
214 </script>273 </script>
215 274
216 <template>275 <template>
217- <UDashboardPanel id="marketplaces">276+ <SkPage wide>
218- <template #header>277+ <SkPageHeader title="Marketplaces" subtitle="Skills from git repositories and Claude plugin marketplaces. Import them into your catalog." />
219- <UDashboardNavbar title="Marketplaces" icon="i-lucide-store">278+
220- <template #right>279+ <SkPanel v-if="sourcesLoading && !sources.length" padded><USkeleton class="h-64 w-full" /></SkPanel>
221- <UButton label="Add source" icon="i-lucide-plus" color="neutral" variant="outline" @click="addOpen = true" />280+ <SkNote v-else-if="sourcesError" tone="accent">
222- </template>281+ <div class="flex items-center gap-3">
223- </UDashboardNavbar>282+ <span class="flex-1">Could not load the sources: {{ sourcesError }}</span>
224- </template>283+ <SkButton size="xs" label="Retry" @click="loadSources" />
225-284+ </div>
226- <template #body>285+ </SkNote>
227- <USkeleton v-if="sourcesLoading && !sources.length" class="h-64 w-full" />286+
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 }]" />287+ <div v-else class="grid items-start gap-4 lg:grid-cols-[280px_minmax(0,1fr)]">
229-288+ <!-- Sources -->
230- <div v-else class="grid items-start gap-6 lg:grid-cols-[16rem_minmax(0,1fr)]">289+ <aside class="flex min-w-0 flex-col gap-2.5 lg:sticky lg:top-[72px]">
231- <!-- Sources -->290+ <SkPanel>
232- <aside class="space-y-1">291+ <SkRow
233- <div
234 v-for="s in sources"292 v-for="s in sources"
235 :key="s.id"293 :key="s.id"
236- class="group flex cursor-pointer items-start gap-2 rounded-lg border px-3 py-2 transition-colors"294+ interactive
237- :class="s.id === selectedId ? 'border-primary bg-primary/5' : 'border-default hover:bg-elevated/50'"295+ :selected="s.id === selectedId"
296+ class="!gap-2 !py-2.5"
297+ role="button"
238 tabindex="0"298 tabindex="0"
299+ :aria-current="s.id === selectedId ? 'true' : undefined"
239 @click="select(s.id)"300 @click="select(s.id)"
240 @keydown.enter="select(s.id)"301 @keydown.enter="select(s.id)"
241 >302 >
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">303 <div class="min-w-0 flex-1">
244 <div class="flex items-center gap-1.5">304 <div class="flex items-center gap-1.5">
245- <span class="truncate text-sm font-medium">{{ s.name }}</span>305+ <span class="truncate font-medium">{{ s.name }}</span>
246- <UBadge v-if="s.builtIn" label="built-in" size="sm" color="neutral" variant="subtle" />306+ <SkTag v-if="s.builtIn">built-in</SkTag>
247 </div>307 </div>
248- <p class="truncate text-xs text-muted" :title="s.url">{{ s.url }}</p>308+ <p class="m-0 truncate font-mono text-[11px] text-sk-faint" :title="s.url">{{ s.url }}</p>
249 </div>309 </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" />310+ <UTooltip v-if="!s.builtIn" text="Remove source">
251- </div>311+ <SkButton size="xs" variant="ghost" icon="i-lucide-trash-2" :aria-label="`Remove ${s.name}`" @click.stop="removeTarget = s" />
252- </aside>312+ </UTooltip>
253-313+ </SkRow>
254- <!-- Selected source -->314+ </SkPanel>
255- <section v-if="selected" class="min-w-0 space-y-4">315+ <SkButton label="Add source" icon="i-lucide-plus" @click="addOpen = true" />
316+ </aside>
317+
318+ <!-- Selected source -->
319+ <section v-if="selected" class="flex min-w-0 flex-col gap-3.5">
320+ <SkPanel padded class="flex flex-col gap-3.5">
256 <div class="flex flex-wrap items-center gap-3">321 <div class="flex flex-wrap items-center gap-3">
257- <div class="min-w-0">322+ <div class="min-w-0 flex-1">
258- <h2 class="truncate font-semibold">{{ selected.name }}</h2>323+ <h2 class="m-0 truncate text-[18px] leading-tight font-semibold tracking-[-0.01em]">{{ 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>324+ <p class="m-0 mt-0.5 text-sk-muted">
325+ {{ KIND_LABEL[selected.kind] }}<template v-if="selected.ref"> · <span class="font-mono text-xs">{{ selected.ref }}</span></template> · {{ sinceFetched(selected) }}
326+ </p>
260 </div>327 </div>
261- <UButton class="ml-auto" label="Refresh" icon="i-lucide-refresh-cw" color="neutral" variant="outline" :loading="refreshing" @click="refreshSource" />328+ <SkButton icon="i-lucide-refresh-cw" label="Refresh" :loading="refreshing" :disabled="skillsLoading" @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>329 </div>
270 330
271- <div v-if="skillsLoading" class="space-y-2">331+ <SkNote v-if="fetching" tone="info">
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." />332+ <p class="m-0 font-medium">{{ refreshing ? 'Fetching the repository…' : 'Loading the skills…' }} <span class="font-mono text-xs text-sk-faint">{{ elapsed }}s</span></p>
273- <USkeleton v-for="i in 4" :key="i" class="h-12 w-full" />333+ <p class="m-0 text-xs text-sk-muted">The first load clones the repository, which can take a minute for large ones. You can keep using skilly meanwhile.</p>
274- </div>334+ </SkNote>
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 }]" />335+
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">336+ <div class="flex flex-wrap items-center gap-2.5">
277- <UIcon :name="search ? 'i-lucide-search-x' : 'i-lucide-store'" class="size-8 text-dimmed" />337+ <UInput v-model="search" icon="i-lucide-search" placeholder="Search name, plugin or description" aria-label="Search skills" class="min-w-0 flex-1 basis-64" />
278- <p class="text-sm text-muted">{{ search ? 'No skill matches the search.' : 'No skill found. Try Refresh.' }}</p>338+ <SkSeg
339+ v-model="onlyNew"
340+ size="md"
341+ aria-label="Filter"
342+ :options="[{ value: 'all', label: 'All', count: skills.length }, { value: 'new', label: 'Not in catalog', count: skills.length - inCatalogCount }]"
343+ />
279 </div>344 </div>
345+ </SkPanel>
280 346
281- <div v-else class="space-y-3">347+ <SkNote v-if="skillsError && !skillsLoading" tone="accent">
282- <div v-for="g in groups" :key="g.plugin ?? 'all'" class="overflow-hidden rounded-xl border border-default">348+ <div class="flex items-start gap-3">
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">349+ <div class="min-w-0 flex-1">
284- <UCheckbox :model-value="groupState(g.rows)" :aria-label="`Select plugin ${g.plugin}`" @update:model-value="(v) => toggleGroup(g.rows, v)" />350+ <p class="m-0 font-semibold">Could not load the skills</p>
285- <UIcon name="i-lucide-puzzle" class="size-3.5" />{{ g.plugin }} ({{ g.rows.length }})351+ <p class="m-0 font-mono text-xs break-words whitespace-pre-wrap">{{ skillsError }}</p>
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>352 </div>
353+ <SkButton size="xs" label="Refresh source" :disabled="refreshing" @click="refreshSource" />
306 </div>354 </div>
307- </section>355+ </SkNote>
308- </div>
309 356
310- <!-- Preview -->357+ <SkPanel v-else-if="skillsLoading && !skills.length" padded class="flex flex-col gap-2">
311- <USlideover v-model:open="previewOpen" :title="preview?.name ?? 'Preview'" :description="preview?.description" :ui="{ content: 'sm:max-w-2xl', description: 'line-clamp-3' }">358+ <USkeleton v-for="i in 5" :key="i" class="h-11 w-full" />
312- <template #body>359+ </SkPanel>
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>360+
314- <UAlert v-else-if="previewError" color="error" variant="subtle" icon="i-lucide-circle-alert" title="Could not load the preview" :description="previewError" />361+ <SkPanel v-else-if="!filtered.length">
315- <div v-else-if="preview" class="space-y-4">362+ <SkEmpty :text="search || onlyNew === 'new' ? 'Nothing matches these filters.' : 'No skill found in this source. Try Refresh.'" />
316- <div v-if="previewFm" class="overflow-hidden rounded-lg border border-default">363+ </SkPanel>
317- <table class="w-full text-xs">364+
318- <tbody class="divide-y divide-default">365+ <SkTray v-else>
319- <tr v-for="(v, k) in previewFm" :key="k">366+ <template v-for="g in groups" :key="g.plugin ?? 'all'">
320- <th class="w-1/4 bg-elevated/40 px-2 py-1.5 text-left align-top font-medium text-muted">{{ k }}</th>367+ <div v-if="g.plugin" class="flex items-center gap-2 px-2.5 pt-2 pb-1">
321- <td class="px-2 py-1.5 break-words">{{ v }}</td>368+ <UCheckbox :model-value="groupState(g.rows)" :aria-label="`Select every skill of ${g.plugin}`" @update:model-value="(v: boolean | 'indeterminate') => toggleGroup(g.rows, v)" />
322- </tr>369+ <SkSectionLabel :count="g.rows.length">{{ g.plugin }}</SkSectionLabel>
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>370 </div>
371+ <SkRowCard
372+ v-for="s in g.rows"
373+ :key="s.path"
374+ cols="16px minmax(0,1fr) auto"
375+ interactive
376+ role="button"
377+ tabindex="0"
378+ @click="openPreview(s)"
379+ @keydown.enter.self="openPreview(s)"
380+ >
381+ <span class="flex" @click.stop>
382+ <UCheckbox :model-value="picked.has(s.path)" :aria-label="`Select ${s.name}`" @update:model-value="(v: boolean | 'indeterminate') => toggle(s.path, v)" />
383+ </span>
384+ <div class="min-w-0">
385+ <span class="block truncate font-medium">{{ s.name }}</span>
386+ <p v-if="s.description" class="m-0 line-clamp-2 text-xs text-pretty text-sk-muted">{{ s.description }}</p>
387+ <p class="m-0 truncate font-mono text-[11px] text-sk-faint" :title="s.path">{{ s.path }}</p>
388+ </div>
389+ <SkStatus v-if="stateOf(s)" :tone="stateOf(s)!.tone" :label="stateOf(s)!.label" muted />
390+ </SkRowCard>
391+ </template>
392+ <div v-if="filtered.length > shown.length" class="py-2 text-center">
393+ <SkButton size="sm" variant="ghost" :label="`Show all ${filtered.length}`" @click="showAll = true" />
334 </div>394 </div>
335- </template>395+ </SkTray>
336- </USlideover>396+ </section>
337-397+ <SkPanel v-else padded><SkEmpty text="Select a source to browse its skills." /></SkPanel>
338- <!-- Overwrite confirmation -->398+ </div>
339- <UModal v-model:open="confirmOpen" title="Overwrite catalog items?" description="Some selected skills already exist in the catalog with different content.">399+
340- <template #body>400+ <SkActionBar v-if="picked.size" :changes="picked.size" noun="selected" label="Import to catalog" :busy="importing" @review="startImport([...picked])" />
341- <ul class="list-disc space-y-0.5 pl-4 text-sm">401+
342- <li v-for="s in differing" :key="s.path">{{ s.name }}</li>402+ <!-- Preview -->
343- </ul>403+ <SkDrawer v-model:open="previewOpen" width="lg">
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>404+ <template #header>
345- </template>405+ <div class="flex min-w-0 flex-1 flex-col gap-1">
346- <template #footer>406+ <div class="flex items-baseline gap-2">
347- <div class="flex w-full justify-end gap-2">407+ <h2 class="m-0 truncate text-[18px] leading-tight font-semibold tracking-[-0.01em]">{{ preview?.name ?? previewSkill?.name ?? 'Preview' }}</h2>
348- <UButton label="Cancel" color="neutral" variant="outline" @click="confirmOpen = false" />408+ <span class="font-mono text-[11px] text-sk-faint">Skill</span>
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>409 </div>
370- </template>410+ <p v-if="preview?.description ?? previewSkill?.description" class="m-0 text-pretty text-sk-muted">{{ preview?.description ?? previewSkill?.description }}</p>
371- </UModal>411+ </div>
372-412+ </template>
373- <UModal :open="!!removeTarget" title="Remove source" description="Imported catalog items are kept." @update:open="(v: boolean) => { if (!v) removeTarget = null }">413+
374- <template #body>414+ <div v-if="previewLoading" class="flex flex-col gap-2"><USkeleton class="h-4 w-1/3" /><USkeleton class="h-4 w-full" /><USkeleton class="h-4 w-5/6" /></div>
375- <p class="text-sm">Remove <strong>{{ removeTarget?.name }}</strong> from the list of sources?</p>415+ <SkNote v-else-if="previewError" tone="accent">Could not load the preview: {{ previewError }}</SkNote>
376- </template>416+ <div v-else-if="preview" class="flex flex-col gap-4">
377- <template #footer>417+ <SkMetaGrid label-width="100px">
378- <div class="flex w-full justify-end gap-2">418+ <SkMeta label="Source">{{ selected?.name }}</SkMeta>
379- <UButton label="Cancel" color="neutral" variant="outline" @click="removeTarget = null" />419+ <SkMeta v-if="previewSkill?.plugin" label="Plugin" mono>{{ previewSkill.plugin }}</SkMeta>
380- <UButton label="Remove" color="error" @click="removeSource" />420+ <SkMeta label="Path" mono>{{ preview.path }}</SkMeta>
421+ <SkMeta v-if="previewSkill && stateOf(previewSkill)" label="Catalog"><SkStatus :tone="stateOf(previewSkill)!.tone" :label="stateOf(previewSkill)!.label" /></SkMeta>
422+ <SkMeta v-for="(v, k) in previewParts?.frontmatter ?? {}" :key="k" :label="String(k)">{{ typeof v === 'string' ? v : JSON.stringify(v) }}</SkMeta>
423+ </SkMetaGrid>
424+ <MarkdownBody :source="previewParts?.body ?? ''" />
425+ <div v-if="preview.files.length" class="flex flex-col gap-2">
426+ <SkSectionLabel :count="preview.files.length">Files</SkSectionLabel>
427+ <SkTray class="max-h-52 overflow-auto">
428+ <SkRowCard v-for="f in preview.files" :key="f" class="truncate font-mono text-xs">{{ f }}</SkRowCard>
429+ </SkTray>
430+ </div>
431+ </div>
432+
433+ <template #footer>
434+ <span class="mr-auto text-xs text-sk-faint">Imports the whole skill folder.</span>
435+ <SkButton label="Close" @click="previewOpen = false" />
436+ <SkButton variant="primary" label="Import to catalog" :loading="importing" :disabled="!previewSkill || !!previewError" @click="previewSkill && startImport([previewSkill.path])" />
437+ </template>
438+ </SkDrawer>
439+
440+ <!-- Overwrite confirmation -->
441+ <SkModal v-model:open="confirmOpen" :title="`Overwrite ${plural(differing.length, 'catalog skill')}?`" width="sm">
442+ <div class="flex flex-col gap-3.5">
443+ <div class="flex items-start gap-2.5">
444+ <SkStepBadge>1</SkStepBadge>
445+ <div class="min-w-0">
446+ <p class="m-0 font-medium">These skills already exist with different content</p>
447+ <ul class="m-0 mt-1 list-none p-0 font-mono text-xs text-sk-muted">
448+ <li v-for="s in differing" :key="s.path" class="truncate">{{ s.name }}</li>
449+ </ul>
381 </div>450 </div>
382- </template>451+ </div>
383- </UModal>452+ <div class="flex items-start gap-2.5">
384- </template>453+ <SkStepBadge>2</SkStepBadge>
385- </UDashboardPanel>454+ <p class="m-0 text-pretty text-sk-muted">The catalog version is replaced by the marketplace version. Local edits are lost from the working folder; git history keeps them. Projects using the skill will show an update.</p>
455+ </div>
456+ </div>
457+ <template #footer>
458+ <SkButton label="Cancel" @click="confirmOpen = false" />
459+ <SkButton variant="primary" label="Overwrite and import" :loading="importing" @click="doImport(true)" />
460+ </template>
461+ </SkModal>
462+
463+ <!-- Add source -->
464+ <SkModal v-model:open="addOpen" title="Add a source" description="A git repository containing skills, or a Claude Code plugin marketplace." width="sm">
465+ <form id="add-source" class="flex flex-col gap-3.5" @submit.prevent="addSource">
466+ <SkNote v-if="addError" tone="accent">{{ addError }}</SkNote>
467+ <label class="flex flex-col gap-1.5"><SkSectionLabel>Name</SkSectionLabel><UInput v-model="addForm.name" placeholder="My skills" /></label>
468+ <div class="flex flex-col gap-1.5">
469+ <SkSectionLabel>Kind</SkSectionLabel>
470+ <SkSeg v-model="addForm.kind" size="md" aria-label="Kind" :options="[{ value: 'git', label: 'Git repository' }, { value: 'claude-marketplace', label: 'Claude marketplace' }]" />
471+ </div>
472+ <label class="flex flex-col gap-1.5"><SkSectionLabel>URL</SkSectionLabel><UInput v-model="addForm.url" placeholder="https://github.com/owner/repo.git" :ui="{ base: 'font-mono' }" /></label>
473+ <label class="flex flex-col gap-1.5">
474+ <SkSectionLabel>Ref</SkSectionLabel>
475+ <UInput v-model="addForm.ref" placeholder="main" :ui="{ base: 'font-mono' }" />
476+ <span class="text-xs text-sk-faint">Branch or tag. The default branch when empty.</span>
477+ </label>
478+ </form>
479+ <template #footer>
480+ <SkButton label="Cancel" @click="addOpen = false" />
481+ <SkButton type="submit" form="add-source" variant="primary" label="Add source" :loading="adding" :disabled="!addForm.name.trim() || !addForm.url.trim()" />
482+ </template>
483+ </SkModal>
484+
485+ <!-- Remove source -->
486+ <SkModal :open="!!removeTarget" title="Remove source" width="sm" @update:open="(v: boolean) => { if (!v) removeTarget = null }">
487+ <p class="m-0 text-pretty">Remove <strong>{{ removeTarget?.name }}</strong> from your sources? Skills already imported stay in the catalog.</p>
488+ <template #footer>
489+ <SkButton label="Cancel" @click="removeTarget = null" />
490+ <SkButton variant="primary" label="Remove source" @click="removeSource" />
491+ </template>
492+ </SkModal>
493+ </SkPage>
386 </template>494 </template>
modified packages/ui/app/pages/onboarding.vue +376 -4
@@ -1,12 +1,384 @@
11 <script setup lang="ts">
2-// PLACEHOLDER (WP3 owns this page). Uses the bare layout (no header): the 860px card of the mockup goes here.
2+import type { CatalogResponse, DiscoveredProject, FsDiscovery, FsListing, Settings } from '@skilly/core'
3+
4+/**
5+ * Onboarding (design/BRIEF.md 2.1 + 4.8), bare layout. Five steps: Welcome, Catalog (restore = git clone, or start
6+ * a new one), Agents (read-only detection summary), Projects (discovered folders), Ready. Skippable at any time.
7+ */
38 definePageMeta({ layout: 'bare' })
9+
10+const { request } = useApi()
11+const catalogState = useCatalog()
12+const { projects, refresh: refreshProjects, add: addProject } = useProjects()
13+const { home, refresh: refreshHome } = useHomeInventory()
14+const router = useRouter()
15+
16+const plural = (n: number, w: string) => `${n} ${w}${n === 1 ? '' : 's'}`
17+const STEPS = ['Welcome', 'Catalog', 'Agents', 'Projects', 'Ready'] as const
18+const step = ref(0)
19+const mode = ref<'restore' | 'fresh' | null>(null)
20+
21+/* ------------------------------------------------ Catalog */
22+
23+const settings = ref<Settings | null>(null)
24+const catalogPath = ref('')
25+const remoteUrl = ref('')
26+const urlTouched = ref(false)
27+const connecting = ref(false)
28+const connectError = ref<string | null>(null)
29+const result = ref<CatalogResponse | null>(null)
30+const folderPicker = ref(false)
31+
32+const URL_RE = /^(?:(?:https?|ssh|git|file):\/\/\S+|[\w.-]+@[\w.-]+:\S+|[A-Za-z]:[\\/]\S*|\/\S+)$/
33+const urlValid = computed(() => URL_RE.test(remoteUrl.value.trim()))
34+const urlError = computed(() => (urlTouched.value && remoteUrl.value.trim() && !urlValid.value
35+ ? 'Use an SSH address (git@github.com:you/catalog.git) or an HTTPS URL.'
36+ : null))
37+const connected = computed(() => !!result.value?.git.isRepo && mode.value === 'restore')
38+const existing = computed(() => (catalogState.data.value?.snapshot.exists ? catalogState.data.value : null))
39+
40+onMounted(async () => {
41+ try {
42+ settings.value = await request<Settings>('/api/settings', { silent: true })
43+ catalogPath.value = settings.value.catalogPath
44+ } catch { /* the step shows an error when acting */ }
45+ await Promise.all([catalogState.refresh(), refreshProjects(), refreshHome()])
46+ if (catalogState.exists.value) result.value = catalogState.data.value
47+})
48+
49+async function connect() {
50+ urlTouched.value = true
51+ if (!urlValid.value) return
52+ connecting.value = true
53+ connectError.value = null
54+ try {
55+ const body: { url: string, path?: string } = { url: remoteUrl.value.trim() }
56+ if (catalogPath.value && catalogPath.value !== settings.value?.catalogPath) body.path = catalogPath.value
57+ result.value = await request<CatalogResponse>('/api/catalog/clone', { method: 'POST', body, silent: true })
58+ catalogState.data.value = result.value
59+ if (settings.value) settings.value = { ...settings.value, catalogPath: result.value.snapshot.root }
60+ } catch (e) {
61+ connectError.value = (e as Error).message
62+ } finally {
63+ connecting.value = false
64+ }
65+}
66+
67+async function createCatalog() {
68+ connecting.value = true
69+ connectError.value = null
70+ try {
71+ if (catalogPath.value && catalogPath.value !== settings.value?.catalogPath) {
72+ settings.value = await request<Settings>('/api/settings', { method: 'PUT', body: { catalogPath: catalogPath.value }, silent: true })
73+ }
74+ result.value = await request<CatalogResponse>('/api/catalog/init', { method: 'POST', silent: true })
75+ catalogState.data.value = result.value
76+ } catch (e) {
77+ connectError.value = (e as Error).message
78+ } finally {
79+ connecting.value = false
80+ }
81+}
82+
83+const catalogReady = computed(() => !!result.value?.snapshot.exists)
84+
85+/* ------------------------------------------------ Agents */
86+
87+const AGENTS = [
88+ { id: 'claude', name: 'Claude Code', where: '~/.claude' },
89+ { id: 'codex', name: 'Codex', where: '~/.codex · ~/.agents' }
90+] as const
91+const agentRows = computed(() => AGENTS.map(a => ({
92+ ...a,
93+ found: !!home.value?.agentsDetected.includes(a.id),
94+ count: home.value?.items.filter(i => i.agent === a.id && i.type !== 'instructions').length ?? 0
95+})))
96+
97+/* ------------------------------------------------ Projects */
98+
99+interface Candidate extends DiscoveredProject { checked: boolean, added: boolean }
100+const candidates = ref<Candidate[]>([])
101+const scanning = ref(false)
102+const scanned = ref(false)
103+const addingProjects = ref(false)
104+const addedCount = ref(0)
105+const addErrors = ref<string[]>([])
106+const scanPicker = ref(false)
107+const addPicker = ref(false)
108+const ROOT_NAMES = new Set(['code', 'dev', 'work', 'projects', 'project', 'src', 'repos', 'repo', 'git', 'workspace', 'workspaces', 'developer', 'sites', 'github'])
109+
110+const sameKey = (p: string) => p.replace(/[\\/]+$/, '').toLowerCase()
111+const known = computed(() => new Set(projects.value.map(p => sameKey(p.path))))
112+const homeDir = computed(() => home.value?.root ?? '')
113+function tilde(p: string): string {
114+ const h = homeDir.value
115+ return h && sameKey(p).startsWith(sameKey(h) + (p.includes('\\') ? '\\' : '/')) ? '~' + p.slice(h.length).replace(/\\/g, '/') : p
116+}
117+
118+function merge(found: DiscoveredProject[], checked = true) {
119+ const have = new Set(candidates.value.map(c => sameKey(c.path)))
120+ for (const f of found) {
121+ if (have.has(sameKey(f.path))) continue
122+ const added = known.value.has(sameKey(f.path))
123+ candidates.value.push({ ...f, added, checked: checked && !added })
124+ }
125+}
126+
127+async function discover(root: string, depth = 3): Promise<DiscoveredProject[]> {
128+ try {
129+ return (await request<FsDiscovery>('/api/fs/discover', { query: { root, depth }, silent: true })).entries
130+ } catch {
131+ return []
132+ }
133+}
134+
135+async function scanDefaults() {
136+ scanning.value = true
137+ try {
138+ const roots = await request<FsListing>('/api/fs/list', { silent: true })
139+ const parents = roots.entries.map(e => e.path)
140+ const dirs: string[] = []
141+ await Promise.all(parents.map(async (p) => {
142+ try {
143+ const l = await request<FsListing>('/api/fs/list', { query: { path: p }, silent: true })
144+ for (const e of l.entries) if (ROOT_NAMES.has(e.name.toLowerCase())) dirs.push(e.path)
145+ } catch { /* unreadable root */ }
146+ }))
147+ const found = (await Promise.all(dirs.slice(0, 8).map(d => discover(d)))).flat()
148+ merge(found)
149+ } finally {
150+ scanning.value = false
151+ scanned.value = true
152+ }
153+}
154+
155+async function scanFolder(path: string) {
156+ scanning.value = true
157+ try { merge(await discover(path, 3)) } finally { scanning.value = false }
158+}
159+
160+function addFolder(path: string) {
161+ const name = path.replace(/[\\/]+$/, '').split(/[\\/]/).pop() || path
162+ merge([{ name, path, hasAgentConfig: false, markers: [] }])
163+ const c = candidates.value.find(x => sameKey(x.path) === sameKey(path))
164+ if (c && !c.added) c.checked = true
165+}
166+
167+const selectedCandidates = computed(() => candidates.value.filter(c => c.checked && !c.added))
168+
169+async function addSelected(): Promise<boolean> {
170+ addingProjects.value = true
171+ addErrors.value = []
172+ let n = 0
173+ for (const c of selectedCandidates.value) {
174+ try {
175+ await addProject(c.path)
176+ c.added = true
177+ c.checked = false
178+ n++
179+ } catch (e) {
180+ const err = e as { status?: number, message: string }
181+ if (err.status === 409) { c.added = true; c.checked = false } else addErrors.value.push(`${c.name}: ${err.message}`)
182+ }
183+ }
184+ addedCount.value += n
185+ addingProjects.value = false
186+ return addErrors.value.length === 0
187+}
188+
189+/* ------------------------------------------------ Navigation */
190+
191+const SKIP_KEY = 'skilly-onboarding-done'
192+function finish(to = '/projects') {
193+ try { localStorage.setItem(SKIP_KEY, '1') } catch { /* private mode */ }
194+ return router.push(to)
195+}
196+
197+const canContinue = computed(() => {
198+ if (step.value === 0) return false
199+ if (step.value === 1) return mode.value === 'restore' ? connected.value || !!existing.value : true
200+ return true
201+})
202+const nextLabel = computed(() => {
203+ if (step.value === 1 && mode.value === 'fresh' && !catalogReady.value) return 'Skip for now'
204+ if (step.value === 3) return selectedCandidates.value.length ? `Add ${selectedCandidates.value.length} and continue` : 'Skip for now'
205+ if (step.value === 4) return 'Open skilly'
206+ return 'Continue'
207+})
208+
209+async function next() {
210+ if (step.value === 3 && selectedCandidates.value.length && !(await addSelected())) return
211+ if (step.value === 4) return finish()
212+ step.value++
213+}
214+function back() { step.value = Math.max(0, step.value - 1) }
215+function choose(m: 'restore' | 'fresh') { mode.value = m; step.value = 1 }
216+
217+watch(step, (s) => { if (s === 3 && !scanned.value && !scanning.value) scanDefaults() })
218+
219+const summary = computed(() => [
220+ ['Catalog', catalogReady.value ? `${plural(result.value!.snapshot.items.length, 'item')} · ${plural(result.value!.snapshot.presets.length, 'preset')}` : 'Not created yet'],
221+ ['Location', catalogPath.value || '-'],
222+ ['Agents', agentRows.value.filter(a => a.found).map(a => a.name).join(', ') || 'None detected'],
223+ ['Projects', `${projects.value.length} managed${addedCount.value ? ` (${addedCount.value} added now)` : ''}`]
224+] as const)
4225 </script>
5226
6227 <template>
7- <div class="flex flex-1 items-center justify-center p-8">
8- <SkPanel hero class="w-full max-w-[860px]">
9- <SkEmpty title="Onboarding" text="Not built yet (WP3)." />
228+ <div class="flex flex-1 items-center justify-center p-4 sm:p-8">
229+ <SkPanel hero class="grid w-full max-w-[860px] md:min-h-[520px] md:grid-cols-[200px_minmax(0,1fr)]">
230+ <!-- Steps -->
231+ <aside class="hidden flex-col gap-5 border-r border-sk-line2 bg-sk-panel p-[18px] md:flex">
232+ <div class="flex items-center gap-2"><SkLogo /><span class="text-base font-semibold">skilly</span></div>
233+ <ol class="m-0 flex list-none flex-col gap-3 p-0">
234+ <li v-for="(s, i) in STEPS" :key="s" class="flex items-center gap-2.5" :aria-current="i === step ? 'step' : undefined">
235+ <span
236+ class="flex size-5 shrink-0 items-center justify-center rounded-full border text-[11px]"
237+ :class="i < step ? 'border-sk-ink bg-sk-ink text-sk-onink' : i === step ? 'border-sk-ink text-sk-text' : 'border-sk-line text-sk-faint'"
238+ >{{ i < step ? '✓' : i + 1 }}</span>
239+ <span :class="i === step ? 'font-semibold text-sk-text' : i < step ? 'text-sk-muted' : 'text-sk-faint'">{{ s }}</span>
240+ </li>
241+ </ol>
242+ <span class="flex-1" />
243+ <SkButton variant="ghost" size="sm" label="Skip setup" class="self-start" @click="finish()" />
244+ </aside>
245+
246+ <!-- Content -->
247+ <div class="flex min-w-0 flex-col p-[26px] sm:p-[32px]">
248+ <div class="flex flex-1 flex-col gap-4">
249+ <!-- 0 Welcome -->
250+ <template v-if="step === 0">
251+ <h1 class="m-0 mt-3 font-mono text-[26px] leading-tight font-normal tracking-[-0.01em]">Welcome to skilly</h1>
252+ <p class="m-0 max-w-[460px] text-pretty text-sk-muted">Manage the skills, rules, MCP servers, hooks and subagents your coding agents use, across all your projects, from one place.</p>
253+ <div class="mt-3 grid gap-3 sm:grid-cols-2">
254+ <button type="button" class="cursor-pointer rounded-[10px] border border-sk-line bg-sk-panel p-4 text-left transition-colors hover:border-sk-faint" @click="choose('restore')">
255+ <span class="block font-semibold">Restore a catalog</span>
256+ <span class="mt-1 block text-pretty text-sk-muted">I already have a catalog in a git repository. Set up this machine from it.</span>
257+ </button>
258+ <button type="button" class="cursor-pointer rounded-[10px] border border-sk-line bg-sk-panel p-4 text-left transition-colors hover:border-sk-faint" @click="choose('fresh')">
259+ <span class="block font-semibold">Start a new catalog</span>
260+ <span class="mt-1 block text-pretty text-sk-muted">Create an empty catalog and fill it from what is already on this machine.</span>
261+ </button>
262+ </div>
263+ </template>
264+
265+ <!-- 1 Catalog -->
266+ <template v-else-if="step === 1">
267+ <SkSectionLabel>Step 1 of 3</SkSectionLabel>
268+ <h1 class="m-0 font-mono text-[22px] font-normal tracking-[-0.01em]">{{ mode === 'restore' ? 'Connect your catalog' : 'Create your catalog' }}</h1>
269+ <p class="m-0 text-sk-muted">{{ mode === 'restore' ? 'skilly clones the repository and restores your items and presets on this machine.' : 'An empty catalog in a git repository. Add a remote later from the Sync page to share it.' }}</p>
270+
271+ <div class="flex flex-col gap-1.5">
272+ <span class="font-medium">Catalog folder</span>
273+ <div class="flex items-center gap-2">
274+ <span class="min-w-0 flex-1 truncate rounded-[7px] border border-sk-line bg-sk-bg px-2.5 py-[7px] font-mono text-xs" :title="catalogPath">{{ catalogPath || '-' }}</span>
275+ <SkButton label="Change" :disabled="connecting || connected || (mode === 'fresh' && catalogReady)" @click="folderPicker = true" />
276+ </div>
277+ </div>
278+
279+ <template v-if="mode === 'restore'">
280+ <form class="flex flex-col gap-1.5" @submit.prevent="connect">
281+ <label class="font-medium" for="remote-url">Git remote URL</label>
282+ <div class="flex items-start gap-2">
283+ <div class="flex min-w-0 flex-1 flex-col gap-1">
284+ <UInput
285+ id="remote-url"
286+ v-model="remoteUrl"
287+ placeholder="git@github.com:you/agent-catalog.git"
288+ :disabled="connecting || connected"
289+ :class="(urlError || connectError) && '[&_input]:ring-sk-accent'"
290+ :ui="{ base: 'font-mono' }"
291+ @blur="urlTouched = true"
292+ @update:model-value="connectError = null"
293+ />
294+ <p v-if="urlError" class="m-0 text-xs text-sk-accent-text">{{ urlError }}</p>
295+ </div>
296+ <SkButton type="submit" variant="primary" :label="connected ? 'Connected' : connecting ? 'Connecting…' : 'Connect'" :loading="connecting" :disabled="connected || !remoteUrl.trim()" />
297+ </div>
298+ <p class="m-0 text-xs text-pretty text-sk-faint">GitHub, GitLab or self-hosted. The catalog never stores secrets: MCP configs only reference environment variable names.</p>
299+ </form>
300+ <SkNote v-if="connecting" tone="info">Cloning the repository… this can take a moment.</SkNote>
301+ <SkNote v-if="connectError" tone="accent"><span class="font-mono text-xs break-words whitespace-pre-wrap">{{ connectError }}</span></SkNote>
302+ <SkNote v-if="connected && result" tone="ok">
303+ Restored {{ plural(result.snapshot.items.length, 'item') }} and {{ plural(result.snapshot.presets.length, 'preset') }}
304+ <template #aside><span class="font-mono text-[11px] text-sk-faint">{{ result.git.branch }}</span></template>
305+ </SkNote>
306+ <SkNote v-else-if="!connected && existing" tone="faint">A catalog already exists at this location ({{ plural(existing.snapshot.items.length, 'item') }}). Change the folder to restore into a new one, or continue to keep it.</SkNote>
307+ </template>
308+
309+ <template v-else>
310+ <SkNote v-if="catalogReady && result" tone="ok">Catalog ready · {{ plural(result.snapshot.items.length, 'item') }}, {{ plural(result.snapshot.presets.length, 'preset') }}</SkNote>
311+ <div v-else><SkButton variant="primary" label="Create the catalog now" :loading="connecting" @click="createCatalog" /></div>
312+ <SkNote v-if="connectError" tone="accent"><span class="font-mono text-xs break-words whitespace-pre-wrap">{{ connectError }}</span></SkNote>
313+ </template>
314+ </template>
315+
316+ <!-- 2 Agents -->
317+ <template v-else-if="step === 2">
318+ <SkSectionLabel>Step 2 of 3</SkSectionLabel>
319+ <h1 class="m-0 font-mono text-[22px] font-normal tracking-[-0.01em]">Agents on this machine</h1>
320+ <p class="m-0 text-sk-muted">What skilly found in your home folder. Each project chooses the agents it writes for.</p>
321+ <SkPanel>
322+ <SkRow v-for="a in agentRows" :key="a.id" cols="minmax(0,1fr) auto" class="!py-3">
323+ <div class="min-w-0">
324+ <div class="font-medium">{{ a.name }}</div>
325+ <div class="font-mono text-[11px] text-sk-faint">{{ a.where }}</div>
326+ </div>
327+ <SkStatus :tone="a.found ? 'ok' : 'faint'" :label="a.found ? `Detected · ${a.count} item${a.count === 1 ? '' : 's'}` : 'Not found'" />
328+ </SkRow>
329+ </SkPanel>
330+ <p v-if="home && !home.agentsDetected.length" class="m-0 text-xs text-sk-faint">Nothing detected yet. You can still manage projects; skilly writes the agent folders when you apply.</p>
331+ </template>
332+
333+ <!-- 3 Projects -->
334+ <template v-else-if="step === 3">
335+ <SkSectionLabel>Step 3 of 3</SkSectionLabel>
336+ <h1 class="m-0 font-mono text-[22px] font-normal tracking-[-0.01em]">Add your projects</h1>
337+ <p class="m-0 text-sk-muted">Folders with agent files, found in your usual code folders. You can add more later.</p>
338+ <SkNote v-if="scanning" tone="info">Looking for projects…</SkNote>
339+ <SkPanel v-if="candidates.length">
340+ <SkRow v-for="c in candidates" :key="c.path" cols="18px minmax(0,1fr) auto" :faded="c.added">
341+ <UCheckbox v-model="c.checked" :disabled="c.added" :aria-label="`Add ${c.name}`" />
342+ <div class="min-w-0">
343+ <div class="truncate font-medium">{{ c.name }}</div>
344+ <div class="truncate font-mono text-[11px] text-sk-faint" :title="c.path">{{ tilde(c.path) }}</div>
345+ </div>
346+ <span v-if="c.added" class="text-xs text-sk-faint">Already added</span>
347+ <span v-else class="font-mono text-[11px] text-sk-faint">{{ c.markers.filter(m => m.endsWith('.md')).join(' · ') || c.markers.join(' · ') }}</span>
348+ </SkRow>
349+ </SkPanel>
350+ <SkEmpty v-else-if="scanned && !scanning" dashed text="No project with agent files found in the usual folders. Add a folder, or scan one." />
351+ <SkNote v-for="e in addErrors" :key="e" tone="accent">{{ e }}</SkNote>
352+ <div class="flex flex-wrap gap-2">
353+ <SkButton label="Add a folder…" @click="addPicker = true" />
354+ <SkButton variant="ghost" label="Scan a folder…" @click="scanPicker = true" />
355+ </div>
356+ </template>
357+
358+ <!-- 4 Ready -->
359+ <template v-else>
360+ <h1 class="m-0 mt-3 font-mono text-[26px] leading-tight font-normal tracking-[-0.01em]">You're set up</h1>
361+ <p class="m-0 max-w-[480px] text-pretty text-sk-muted">skilly writes the enabled items into each project. The files are committed with the project, so it works without skilly too.</p>
362+ <SkPanel>
363+ <SkRow v-for="[k, v] in summary" :key="k" cols="110px minmax(0,1fr)">
364+ <span class="text-sk-muted">{{ k }}</span>
365+ <span class="truncate text-right" :class="k === 'Location' && 'font-mono text-xs'" :title="v">{{ v }}</span>
366+ </SkRow>
367+ </SkPanel>
368+ </template>
369+ </div>
370+
371+ <!-- Footer -->
372+ <div v-if="step > 0" class="mt-6 flex items-center justify-between gap-3">
373+ <SkButton variant="ghost" label="Back" @click="back" />
374+ <SkButton variant="primary" :label="nextLabel" :loading="addingProjects" :disabled="!canContinue" @click="next" />
375+ </div>
376+ <div v-else class="mt-6"><SkButton variant="ghost" label="Skip setup" class="md:hidden" @click="finish()" /></div>
377+ </div>
10378 </SkPanel>
379+
380+ <FolderPickerModal v-model:open="folderPicker" title="Choose the catalog folder" :start="catalogPath" @pick="(p: string) => catalogPath = p" />
381+ <FolderPickerModal v-model:open="addPicker" title="Add a project folder" @pick="addFolder" />
382+ <FolderPickerModal v-model:open="scanPicker" title="Scan a folder for projects" @pick="scanFolder" />
11383 </div>
12384 </template>
@@ -1,12 +1,384 @@
1 <script setup lang="ts">1 <script setup lang="ts">
2-// PLACEHOLDER (WP3 owns this page). Uses the bare layout (no header): the 860px card of the mockup goes here.2+import type { CatalogResponse, DiscoveredProject, FsDiscovery, FsListing, Settings } from '@skilly/core'
3+
4+/**
5+ * Onboarding (design/BRIEF.md 2.1 + 4.8), bare layout. Five steps: Welcome, Catalog (restore = git clone, or start
6+ * a new one), Agents (read-only detection summary), Projects (discovered folders), Ready. Skippable at any time.
7+ */
3 definePageMeta({ layout: 'bare' })8 definePageMeta({ layout: 'bare' })
9+
10+const { request } = useApi()
11+const catalogState = useCatalog()
12+const { projects, refresh: refreshProjects, add: addProject } = useProjects()
13+const { home, refresh: refreshHome } = useHomeInventory()
14+const router = useRouter()
15+
16+const plural = (n: number, w: string) => `${n} ${w}${n === 1 ? '' : 's'}`
17+const STEPS = ['Welcome', 'Catalog', 'Agents', 'Projects', 'Ready'] as const
18+const step = ref(0)
19+const mode = ref<'restore' | 'fresh' | null>(null)
20+
21+/* ------------------------------------------------ Catalog */
22+
23+const settings = ref<Settings | null>(null)
24+const catalogPath = ref('')
25+const remoteUrl = ref('')
26+const urlTouched = ref(false)
27+const connecting = ref(false)
28+const connectError = ref<string | null>(null)
29+const result = ref<CatalogResponse | null>(null)
30+const folderPicker = ref(false)
31+
32+const URL_RE = /^(?:(?:https?|ssh|git|file):\/\/\S+|[\w.-]+@[\w.-]+:\S+|[A-Za-z]:[\\/]\S*|\/\S+)$/
33+const urlValid = computed(() => URL_RE.test(remoteUrl.value.trim()))
34+const urlError = computed(() => (urlTouched.value && remoteUrl.value.trim() && !urlValid.value
35+ ? 'Use an SSH address (git@github.com:you/catalog.git) or an HTTPS URL.'
36+ : null))
37+const connected = computed(() => !!result.value?.git.isRepo && mode.value === 'restore')
38+const existing = computed(() => (catalogState.data.value?.snapshot.exists ? catalogState.data.value : null))
39+
40+onMounted(async () => {
41+ try {
42+ settings.value = await request<Settings>('/api/settings', { silent: true })
43+ catalogPath.value = settings.value.catalogPath
44+ } catch { /* the step shows an error when acting */ }
45+ await Promise.all([catalogState.refresh(), refreshProjects(), refreshHome()])
46+ if (catalogState.exists.value) result.value = catalogState.data.value
47+})
48+
49+async function connect() {
50+ urlTouched.value = true
51+ if (!urlValid.value) return
52+ connecting.value = true
53+ connectError.value = null
54+ try {
55+ const body: { url: string, path?: string } = { url: remoteUrl.value.trim() }
56+ if (catalogPath.value && catalogPath.value !== settings.value?.catalogPath) body.path = catalogPath.value
57+ result.value = await request<CatalogResponse>('/api/catalog/clone', { method: 'POST', body, silent: true })
58+ catalogState.data.value = result.value
59+ if (settings.value) settings.value = { ...settings.value, catalogPath: result.value.snapshot.root }
60+ } catch (e) {
61+ connectError.value = (e as Error).message
62+ } finally {
63+ connecting.value = false
64+ }
65+}
66+
67+async function createCatalog() {
68+ connecting.value = true
69+ connectError.value = null
70+ try {
71+ if (catalogPath.value && catalogPath.value !== settings.value?.catalogPath) {
72+ settings.value = await request<Settings>('/api/settings', { method: 'PUT', body: { catalogPath: catalogPath.value }, silent: true })
73+ }
74+ result.value = await request<CatalogResponse>('/api/catalog/init', { method: 'POST', silent: true })
75+ catalogState.data.value = result.value
76+ } catch (e) {
77+ connectError.value = (e as Error).message
78+ } finally {
79+ connecting.value = false
80+ }
81+}
82+
83+const catalogReady = computed(() => !!result.value?.snapshot.exists)
84+
85+/* ------------------------------------------------ Agents */
86+
87+const AGENTS = [
88+ { id: 'claude', name: 'Claude Code', where: '~/.claude' },
89+ { id: 'codex', name: 'Codex', where: '~/.codex · ~/.agents' }
90+] as const
91+const agentRows = computed(() => AGENTS.map(a => ({
92+ ...a,
93+ found: !!home.value?.agentsDetected.includes(a.id),
94+ count: home.value?.items.filter(i => i.agent === a.id && i.type !== 'instructions').length ?? 0
95+})))
96+
97+/* ------------------------------------------------ Projects */
98+
99+interface Candidate extends DiscoveredProject { checked: boolean, added: boolean }
100+const candidates = ref<Candidate[]>([])
101+const scanning = ref(false)
102+const scanned = ref(false)
103+const addingProjects = ref(false)
104+const addedCount = ref(0)
105+const addErrors = ref<string[]>([])
106+const scanPicker = ref(false)
107+const addPicker = ref(false)
108+const ROOT_NAMES = new Set(['code', 'dev', 'work', 'projects', 'project', 'src', 'repos', 'repo', 'git', 'workspace', 'workspaces', 'developer', 'sites', 'github'])
109+
110+const sameKey = (p: string) => p.replace(/[\\/]+$/, '').toLowerCase()
111+const known = computed(() => new Set(projects.value.map(p => sameKey(p.path))))
112+const homeDir = computed(() => home.value?.root ?? '')
113+function tilde(p: string): string {
114+ const h = homeDir.value
115+ return h && sameKey(p).startsWith(sameKey(h) + (p.includes('\\') ? '\\' : '/')) ? '~' + p.slice(h.length).replace(/\\/g, '/') : p
116+}
117+
118+function merge(found: DiscoveredProject[], checked = true) {
119+ const have = new Set(candidates.value.map(c => sameKey(c.path)))
120+ for (const f of found) {
121+ if (have.has(sameKey(f.path))) continue
122+ const added = known.value.has(sameKey(f.path))
123+ candidates.value.push({ ...f, added, checked: checked && !added })
124+ }
125+}
126+
127+async function discover(root: string, depth = 3): Promise<DiscoveredProject[]> {
128+ try {
129+ return (await request<FsDiscovery>('/api/fs/discover', { query: { root, depth }, silent: true })).entries
130+ } catch {
131+ return []
132+ }
133+}
134+
135+async function scanDefaults() {
136+ scanning.value = true
137+ try {
138+ const roots = await request<FsListing>('/api/fs/list', { silent: true })
139+ const parents = roots.entries.map(e => e.path)
140+ const dirs: string[] = []
141+ await Promise.all(parents.map(async (p) => {
142+ try {
143+ const l = await request<FsListing>('/api/fs/list', { query: { path: p }, silent: true })
144+ for (const e of l.entries) if (ROOT_NAMES.has(e.name.toLowerCase())) dirs.push(e.path)
145+ } catch { /* unreadable root */ }
146+ }))
147+ const found = (await Promise.all(dirs.slice(0, 8).map(d => discover(d)))).flat()
148+ merge(found)
149+ } finally {
150+ scanning.value = false
151+ scanned.value = true
152+ }
153+}
154+
155+async function scanFolder(path: string) {
156+ scanning.value = true
157+ try { merge(await discover(path, 3)) } finally { scanning.value = false }
158+}
159+
160+function addFolder(path: string) {
161+ const name = path.replace(/[\\/]+$/, '').split(/[\\/]/).pop() || path
162+ merge([{ name, path, hasAgentConfig: false, markers: [] }])
163+ const c = candidates.value.find(x => sameKey(x.path) === sameKey(path))
164+ if (c && !c.added) c.checked = true
165+}
166+
167+const selectedCandidates = computed(() => candidates.value.filter(c => c.checked && !c.added))
168+
169+async function addSelected(): Promise<boolean> {
170+ addingProjects.value = true
171+ addErrors.value = []
172+ let n = 0
173+ for (const c of selectedCandidates.value) {
174+ try {
175+ await addProject(c.path)
176+ c.added = true
177+ c.checked = false
178+ n++
179+ } catch (e) {
180+ const err = e as { status?: number, message: string }
181+ if (err.status === 409) { c.added = true; c.checked = false } else addErrors.value.push(`${c.name}: ${err.message}`)
182+ }
183+ }
184+ addedCount.value += n
185+ addingProjects.value = false
186+ return addErrors.value.length === 0
187+}
188+
189+/* ------------------------------------------------ Navigation */
190+
191+const SKIP_KEY = 'skilly-onboarding-done'
192+function finish(to = '/projects') {
193+ try { localStorage.setItem(SKIP_KEY, '1') } catch { /* private mode */ }
194+ return router.push(to)
195+}
196+
197+const canContinue = computed(() => {
198+ if (step.value === 0) return false
199+ if (step.value === 1) return mode.value === 'restore' ? connected.value || !!existing.value : true
200+ return true
201+})
202+const nextLabel = computed(() => {
203+ if (step.value === 1 && mode.value === 'fresh' && !catalogReady.value) return 'Skip for now'
204+ if (step.value === 3) return selectedCandidates.value.length ? `Add ${selectedCandidates.value.length} and continue` : 'Skip for now'
205+ if (step.value === 4) return 'Open skilly'
206+ return 'Continue'
207+})
208+
209+async function next() {
210+ if (step.value === 3 && selectedCandidates.value.length && !(await addSelected())) return
211+ if (step.value === 4) return finish()
212+ step.value++
213+}
214+function back() { step.value = Math.max(0, step.value - 1) }
215+function choose(m: 'restore' | 'fresh') { mode.value = m; step.value = 1 }
216+
217+watch(step, (s) => { if (s === 3 && !scanned.value && !scanning.value) scanDefaults() })
218+
219+const summary = computed(() => [
220+ ['Catalog', catalogReady.value ? `${plural(result.value!.snapshot.items.length, 'item')} · ${plural(result.value!.snapshot.presets.length, 'preset')}` : 'Not created yet'],
221+ ['Location', catalogPath.value || '-'],
222+ ['Agents', agentRows.value.filter(a => a.found).map(a => a.name).join(', ') || 'None detected'],
223+ ['Projects', `${projects.value.length} managed${addedCount.value ? ` (${addedCount.value} added now)` : ''}`]
224+] as const)
4 </script>225 </script>
5 226
6 <template>227 <template>
7- <div class="flex flex-1 items-center justify-center p-8">228+ <div class="flex flex-1 items-center justify-center p-4 sm:p-8">
8- <SkPanel hero class="w-full max-w-[860px]">229+ <SkPanel hero class="grid w-full max-w-[860px] md:min-h-[520px] md:grid-cols-[200px_minmax(0,1fr)]">
9- <SkEmpty title="Onboarding" text="Not built yet (WP3)." />230+ <!-- Steps -->
231+ <aside class="hidden flex-col gap-5 border-r border-sk-line2 bg-sk-panel p-[18px] md:flex">
232+ <div class="flex items-center gap-2"><SkLogo /><span class="text-base font-semibold">skilly</span></div>
233+ <ol class="m-0 flex list-none flex-col gap-3 p-0">
234+ <li v-for="(s, i) in STEPS" :key="s" class="flex items-center gap-2.5" :aria-current="i === step ? 'step' : undefined">
235+ <span
236+ class="flex size-5 shrink-0 items-center justify-center rounded-full border text-[11px]"
237+ :class="i < step ? 'border-sk-ink bg-sk-ink text-sk-onink' : i === step ? 'border-sk-ink text-sk-text' : 'border-sk-line text-sk-faint'"
238+ >{{ i < step ? '✓' : i + 1 }}</span>
239+ <span :class="i === step ? 'font-semibold text-sk-text' : i < step ? 'text-sk-muted' : 'text-sk-faint'">{{ s }}</span>
240+ </li>
241+ </ol>
242+ <span class="flex-1" />
243+ <SkButton variant="ghost" size="sm" label="Skip setup" class="self-start" @click="finish()" />
244+ </aside>
245+
246+ <!-- Content -->
247+ <div class="flex min-w-0 flex-col p-[26px] sm:p-[32px]">
248+ <div class="flex flex-1 flex-col gap-4">
249+ <!-- 0 Welcome -->
250+ <template v-if="step === 0">
251+ <h1 class="m-0 mt-3 font-mono text-[26px] leading-tight font-normal tracking-[-0.01em]">Welcome to skilly</h1>
252+ <p class="m-0 max-w-[460px] text-pretty text-sk-muted">Manage the skills, rules, MCP servers, hooks and subagents your coding agents use, across all your projects, from one place.</p>
253+ <div class="mt-3 grid gap-3 sm:grid-cols-2">
254+ <button type="button" class="cursor-pointer rounded-[10px] border border-sk-line bg-sk-panel p-4 text-left transition-colors hover:border-sk-faint" @click="choose('restore')">
255+ <span class="block font-semibold">Restore a catalog</span>
256+ <span class="mt-1 block text-pretty text-sk-muted">I already have a catalog in a git repository. Set up this machine from it.</span>
257+ </button>
258+ <button type="button" class="cursor-pointer rounded-[10px] border border-sk-line bg-sk-panel p-4 text-left transition-colors hover:border-sk-faint" @click="choose('fresh')">
259+ <span class="block font-semibold">Start a new catalog</span>
260+ <span class="mt-1 block text-pretty text-sk-muted">Create an empty catalog and fill it from what is already on this machine.</span>
261+ </button>
262+ </div>
263+ </template>
264+
265+ <!-- 1 Catalog -->
266+ <template v-else-if="step === 1">
267+ <SkSectionLabel>Step 1 of 3</SkSectionLabel>
268+ <h1 class="m-0 font-mono text-[22px] font-normal tracking-[-0.01em]">{{ mode === 'restore' ? 'Connect your catalog' : 'Create your catalog' }}</h1>
269+ <p class="m-0 text-sk-muted">{{ mode === 'restore' ? 'skilly clones the repository and restores your items and presets on this machine.' : 'An empty catalog in a git repository. Add a remote later from the Sync page to share it.' }}</p>
270+
271+ <div class="flex flex-col gap-1.5">
272+ <span class="font-medium">Catalog folder</span>
273+ <div class="flex items-center gap-2">
274+ <span class="min-w-0 flex-1 truncate rounded-[7px] border border-sk-line bg-sk-bg px-2.5 py-[7px] font-mono text-xs" :title="catalogPath">{{ catalogPath || '-' }}</span>
275+ <SkButton label="Change" :disabled="connecting || connected || (mode === 'fresh' && catalogReady)" @click="folderPicker = true" />
276+ </div>
277+ </div>
278+
279+ <template v-if="mode === 'restore'">
280+ <form class="flex flex-col gap-1.5" @submit.prevent="connect">
281+ <label class="font-medium" for="remote-url">Git remote URL</label>
282+ <div class="flex items-start gap-2">
283+ <div class="flex min-w-0 flex-1 flex-col gap-1">
284+ <UInput
285+ id="remote-url"
286+ v-model="remoteUrl"
287+ placeholder="git@github.com:you/agent-catalog.git"
288+ :disabled="connecting || connected"
289+ :class="(urlError || connectError) && '[&_input]:ring-sk-accent'"
290+ :ui="{ base: 'font-mono' }"
291+ @blur="urlTouched = true"
292+ @update:model-value="connectError = null"
293+ />
294+ <p v-if="urlError" class="m-0 text-xs text-sk-accent-text">{{ urlError }}</p>
295+ </div>
296+ <SkButton type="submit" variant="primary" :label="connected ? 'Connected' : connecting ? 'Connecting…' : 'Connect'" :loading="connecting" :disabled="connected || !remoteUrl.trim()" />
297+ </div>
298+ <p class="m-0 text-xs text-pretty text-sk-faint">GitHub, GitLab or self-hosted. The catalog never stores secrets: MCP configs only reference environment variable names.</p>
299+ </form>
300+ <SkNote v-if="connecting" tone="info">Cloning the repository… this can take a moment.</SkNote>
301+ <SkNote v-if="connectError" tone="accent"><span class="font-mono text-xs break-words whitespace-pre-wrap">{{ connectError }}</span></SkNote>
302+ <SkNote v-if="connected && result" tone="ok">
303+ Restored {{ plural(result.snapshot.items.length, 'item') }} and {{ plural(result.snapshot.presets.length, 'preset') }}
304+ <template #aside><span class="font-mono text-[11px] text-sk-faint">{{ result.git.branch }}</span></template>
305+ </SkNote>
306+ <SkNote v-else-if="!connected && existing" tone="faint">A catalog already exists at this location ({{ plural(existing.snapshot.items.length, 'item') }}). Change the folder to restore into a new one, or continue to keep it.</SkNote>
307+ </template>
308+
309+ <template v-else>
310+ <SkNote v-if="catalogReady && result" tone="ok">Catalog ready · {{ plural(result.snapshot.items.length, 'item') }}, {{ plural(result.snapshot.presets.length, 'preset') }}</SkNote>
311+ <div v-else><SkButton variant="primary" label="Create the catalog now" :loading="connecting" @click="createCatalog" /></div>
312+ <SkNote v-if="connectError" tone="accent"><span class="font-mono text-xs break-words whitespace-pre-wrap">{{ connectError }}</span></SkNote>
313+ </template>
314+ </template>
315+
316+ <!-- 2 Agents -->
317+ <template v-else-if="step === 2">
318+ <SkSectionLabel>Step 2 of 3</SkSectionLabel>
319+ <h1 class="m-0 font-mono text-[22px] font-normal tracking-[-0.01em]">Agents on this machine</h1>
320+ <p class="m-0 text-sk-muted">What skilly found in your home folder. Each project chooses the agents it writes for.</p>
321+ <SkPanel>
322+ <SkRow v-for="a in agentRows" :key="a.id" cols="minmax(0,1fr) auto" class="!py-3">
323+ <div class="min-w-0">
324+ <div class="font-medium">{{ a.name }}</div>
325+ <div class="font-mono text-[11px] text-sk-faint">{{ a.where }}</div>
326+ </div>
327+ <SkStatus :tone="a.found ? 'ok' : 'faint'" :label="a.found ? `Detected · ${a.count} item${a.count === 1 ? '' : 's'}` : 'Not found'" />
328+ </SkRow>
329+ </SkPanel>
330+ <p v-if="home && !home.agentsDetected.length" class="m-0 text-xs text-sk-faint">Nothing detected yet. You can still manage projects; skilly writes the agent folders when you apply.</p>
331+ </template>
332+
333+ <!-- 3 Projects -->
334+ <template v-else-if="step === 3">
335+ <SkSectionLabel>Step 3 of 3</SkSectionLabel>
336+ <h1 class="m-0 font-mono text-[22px] font-normal tracking-[-0.01em]">Add your projects</h1>
337+ <p class="m-0 text-sk-muted">Folders with agent files, found in your usual code folders. You can add more later.</p>
338+ <SkNote v-if="scanning" tone="info">Looking for projects…</SkNote>
339+ <SkPanel v-if="candidates.length">
340+ <SkRow v-for="c in candidates" :key="c.path" cols="18px minmax(0,1fr) auto" :faded="c.added">
341+ <UCheckbox v-model="c.checked" :disabled="c.added" :aria-label="`Add ${c.name}`" />
342+ <div class="min-w-0">
343+ <div class="truncate font-medium">{{ c.name }}</div>
344+ <div class="truncate font-mono text-[11px] text-sk-faint" :title="c.path">{{ tilde(c.path) }}</div>
345+ </div>
346+ <span v-if="c.added" class="text-xs text-sk-faint">Already added</span>
347+ <span v-else class="font-mono text-[11px] text-sk-faint">{{ c.markers.filter(m => m.endsWith('.md')).join(' · ') || c.markers.join(' · ') }}</span>
348+ </SkRow>
349+ </SkPanel>
350+ <SkEmpty v-else-if="scanned && !scanning" dashed text="No project with agent files found in the usual folders. Add a folder, or scan one." />
351+ <SkNote v-for="e in addErrors" :key="e" tone="accent">{{ e }}</SkNote>
352+ <div class="flex flex-wrap gap-2">
353+ <SkButton label="Add a folder…" @click="addPicker = true" />
354+ <SkButton variant="ghost" label="Scan a folder…" @click="scanPicker = true" />
355+ </div>
356+ </template>
357+
358+ <!-- 4 Ready -->
359+ <template v-else>
360+ <h1 class="m-0 mt-3 font-mono text-[26px] leading-tight font-normal tracking-[-0.01em]">You're set up</h1>
361+ <p class="m-0 max-w-[480px] text-pretty text-sk-muted">skilly writes the enabled items into each project. The files are committed with the project, so it works without skilly too.</p>
362+ <SkPanel>
363+ <SkRow v-for="[k, v] in summary" :key="k" cols="110px minmax(0,1fr)">
364+ <span class="text-sk-muted">{{ k }}</span>
365+ <span class="truncate text-right" :class="k === 'Location' && 'font-mono text-xs'" :title="v">{{ v }}</span>
366+ </SkRow>
367+ </SkPanel>
368+ </template>
369+ </div>
370+
371+ <!-- Footer -->
372+ <div v-if="step > 0" class="mt-6 flex items-center justify-between gap-3">
373+ <SkButton variant="ghost" label="Back" @click="back" />
374+ <SkButton variant="primary" :label="nextLabel" :loading="addingProjects" :disabled="!canContinue" @click="next" />
375+ </div>
376+ <div v-else class="mt-6"><SkButton variant="ghost" label="Skip setup" class="md:hidden" @click="finish()" /></div>
377+ </div>
10 </SkPanel>378 </SkPanel>
379+
380+ <FolderPickerModal v-model:open="folderPicker" title="Choose the catalog folder" :start="catalogPath" @pick="(p: string) => catalogPath = p" />
381+ <FolderPickerModal v-model:open="addPicker" title="Add a project folder" @pick="addFolder" />
382+ <FolderPickerModal v-model:open="scanPicker" title="Scan a folder for projects" @pick="scanFolder" />
11 </div>383 </div>
12 </template>384 </template>
modified packages/ui/app/pages/projects/[id].vue +1 -1
@@ -4,5 +4,5 @@ const id = computed(() => String(route.params.id))
44 </script>
55
66 <template>
7- <ScopeView :key="id" kind="project" :project-id="id" />
7+ <ScopePage :key="id" kind="project" :project-id="id" />
88 </template>
@@ -4,5 +4,5 @@ const id = computed(() => String(route.params.id))
4 </script>4 </script>
5 5
6 <template>6 <template>
7- <ScopeView :key="id" kind="project" :project-id="id" />7+ <ScopePage :key="id" kind="project" :project-id="id" />
8 </template>8 </template>
modified packages/ui/app/pages/projects/index.vue +9 -145
@@ -1,150 +1,14 @@
11 <script setup lang="ts">
2-import type { ItemType, ScopeInventory, ScopeState } from '@skilly/core'
3-
4-const { projects, loaded, error: projectsError, refresh: refreshProjects } = useProjects()
5-const { home, loading: homeLoading, error: homeError, refresh: refreshHome } = useHomeInventory()
6-const { request } = useApi()
7-const addOpen = useState('skilly-add-open', () => false)
8-
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 () => {
38- if (!home.value) refreshHome()
39- // Projects may have changed on disk (e.g. a folder was deleted) since the layout loaded them.
40- if (loaded.value) await refreshProjects()
41- else await new Promise<void>((res) => { const stop = watch(loaded, (v) => { if (v) { stop(); res() } }) })
42- loadSyncs()
43-})
44-
45-function countItems(inv: ScopeInventory): Record<ItemType, number> {
46- const c = Object.fromEntries(TYPE_ORDER.map(t => [t, 0])) as Record<ItemType, number>
47- for (const i of inv.items) c[i.type]++
48- return c
49-}
50-
51-const homeCounts = computed(() => (home.value ? countItems(home.value) : null))
2+const addOpen = useAddProjectModal()
523 </script>
534
545 <template>
55- <UDashboardPanel id="projects">
56- <template #header>
57- <UDashboardNavbar title="Projects">
58- <template #right>
59- <UButton icon="i-lucide-folder-plus" label="Add project" @click="addOpen = true" />
60- </template>
61- </UDashboardNavbar>
62- </template>
63-
64- <template #body>
65- <div class="mx-auto w-full max-w-5xl space-y-6">
66- <p class="text-sm text-muted">
67- Everything your coding agents can see: skills, rules, MCP servers, hooks, subagents and instruction files.
68- </p>
69-
70- <div class="grid gap-3 md:grid-cols-2">
71- <!-- Home -->
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" />
79- </div>
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">
83- <p class="truncate font-mono text-xs text-muted" :title="home.root">{{ home.root }}</p>
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 />
90- </div>
91- </div>
92- </NuxtLink>
93-
94- <!-- Projects -->
95- <template v-if="loaded">
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">
110- <p class="truncate font-mono text-xs text-muted" :title="p.path">{{ p.path }}</p>
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 />
117- </div>
118- </div>
119- </NuxtLink>
120- </template>
121- <template v-else>
122- <USkeleton class="h-24 w-full" />
123- </template>
124- </div>
125-
126- <UAlert
127- v-if="projectsError"
128- color="error"
129- variant="subtle"
130- icon="i-lucide-circle-alert"
131- title="Could not load projects"
132- :description="projectsError"
133- :actions="[{ label: 'Retry', color: 'neutral', variant: 'outline', onClick: refreshProjects }]"
134- />
135-
136- <div
137- v-else-if="loaded && !projects.length"
138- class="flex flex-col items-center gap-3 rounded-xl border border-dashed border-default py-14 text-center"
139- >
140- <UIcon name="i-lucide-folder-plus" class="size-10 text-dimmed" />
141- <h3 class="font-semibold">Add your first project</h3>
142- <p class="max-w-md text-sm text-muted">
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.
144- </p>
145- <UButton icon="i-lucide-folder-plus" label="Add project" @click="addOpen = true" />
146- </div>
147- </div>
148- </template>
149- </UDashboardPanel>
6+ <SkPage>
7+ <SkPageHeader title="Projects" subtitle="Folders managed by skilly. Enabled items are written into each project.">
8+ <template #actions>
9+ <SkButton variant="primary" label="Add project" @click="addOpen = true" />
10+ </template>
11+ </SkPageHeader>
12+ <ProjectsTable />
13+ </SkPage>
15014 </template>
@@ -1,150 +1,14 @@
1 <script setup lang="ts">1 <script setup lang="ts">
2-import type { ItemType, ScopeInventory, ScopeState } from '@skilly/core'2+const addOpen = useAddProjectModal()
3-
4-const { projects, loaded, error: projectsError, refresh: refreshProjects } = useProjects()
5-const { home, loading: homeLoading, error: homeError, refresh: refreshHome } = useHomeInventory()
6-const { request } = useApi()
7-const addOpen = useState('skilly-add-open', () => false)
8-
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 () => {
38- if (!home.value) refreshHome()
39- // Projects may have changed on disk (e.g. a folder was deleted) since the layout loaded them.
40- if (loaded.value) await refreshProjects()
41- else await new Promise<void>((res) => { const stop = watch(loaded, (v) => { if (v) { stop(); res() } }) })
42- loadSyncs()
43-})
44-
45-function countItems(inv: ScopeInventory): Record<ItemType, number> {
46- const c = Object.fromEntries(TYPE_ORDER.map(t => [t, 0])) as Record<ItemType, number>
47- for (const i of inv.items) c[i.type]++
48- return c
49-}
50-
51-const homeCounts = computed(() => (home.value ? countItems(home.value) : null))
52 </script>3 </script>
53 4
54 <template>5 <template>
55- <UDashboardPanel id="projects">6+ <SkPage>
56- <template #header>7+ <SkPageHeader title="Projects" subtitle="Folders managed by skilly. Enabled items are written into each project.">
57- <UDashboardNavbar title="Projects">8+ <template #actions>
58- <template #right>9+ <SkButton variant="primary" label="Add project" @click="addOpen = true" />
59- <UButton icon="i-lucide-folder-plus" label="Add project" @click="addOpen = true" />10+ </template>
60- </template>11+ </SkPageHeader>
61- </UDashboardNavbar>12+ <ProjectsTable />
62- </template>13+ </SkPage>
63-
64- <template #body>
65- <div class="mx-auto w-full max-w-5xl space-y-6">
66- <p class="text-sm text-muted">
67- Everything your coding agents can see: skills, rules, MCP servers, hooks, subagents and instruction files.
68- </p>
69-
70- <div class="grid gap-3 md:grid-cols-2">
71- <!-- Home -->
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" />
79- </div>
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">
83- <p class="truncate font-mono text-xs text-muted" :title="home.root">{{ home.root }}</p>
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 />
90- </div>
91- </div>
92- </NuxtLink>
93-
94- <!-- Projects -->
95- <template v-if="loaded">
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">
110- <p class="truncate font-mono text-xs text-muted" :title="p.path">{{ p.path }}</p>
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 />
117- </div>
118- </div>
119- </NuxtLink>
120- </template>
121- <template v-else>
122- <USkeleton class="h-24 w-full" />
123- </template>
124- </div>
125-
126- <UAlert
127- v-if="projectsError"
128- color="error"
129- variant="subtle"
130- icon="i-lucide-circle-alert"
131- title="Could not load projects"
132- :description="projectsError"
133- :actions="[{ label: 'Retry', color: 'neutral', variant: 'outline', onClick: refreshProjects }]"
134- />
135-
136- <div
137- v-else-if="loaded && !projects.length"
138- class="flex flex-col items-center gap-3 rounded-xl border border-dashed border-default py-14 text-center"
139- >
140- <UIcon name="i-lucide-folder-plus" class="size-10 text-dimmed" />
141- <h3 class="font-semibold">Add your first project</h3>
142- <p class="max-w-md text-sm text-muted">
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.
144- </p>
145- <UButton icon="i-lucide-folder-plus" label="Add project" @click="addOpen = true" />
146- </div>
147- </div>
148- </template>
149- </UDashboardPanel>
150 </template>14 </template>
modified packages/ui/app/pages/settings.vue +79 -42
@@ -1,17 +1,22 @@
11 <script setup lang="ts">
22 import type { Settings } from '@skilly/core'
33
4+/** Settings (brief 3.1): catalog location + automatic sync, appearance, run the setup again, about. */
45 const { request } = useApi()
56 const { refresh: refreshCatalog, data: catalog } = useCatalog()
7+const { projects } = useProjects()
68 const toast = useToast()
9+const colorMode = useColorMode()
710
811 const loaded = ref<Settings | null>(null)
912 const form = reactive<Settings>({ catalogPath: '', autoSync: false })
1013 const error = ref<string | null>(null)
1114 const saving = ref(false)
1215 const pickerOpen = ref(false)
16+const version = ref<string | null>(null)
1317
14-const dirty = computed(() => !!loaded.value && (form.catalogPath !== loaded.value.catalogPath || form.autoSync !== loaded.value.autoSync))
18+const dirty = computed(() => !!loaded.value && (form.catalogPath.trim() !== loaded.value.catalogPath || form.autoSync !== loaded.value.autoSync))
19+const pathChanged = computed(() => !!loaded.value && form.catalogPath.trim() !== loaded.value.catalogPath)
1520
1621 function apply(s: Settings) {
1722 loaded.value = s
@@ -25,60 +30,92 @@ onMounted(async () => {
2530 } catch (e) {
2631 error.value = (e as Error).message
2732 }
33+ if (!catalog.value) refreshCatalog()
34+ try {
35+ version.value = (await request<{ version: string }>('/api/health', { silent: true })).version
36+ } catch { /* the version line just stays hidden */ }
2837 })
2938
3039 async function save() {
3140 saving.value = true
3241 try {
33- apply(await request<Settings>('/api/settings', { method: 'PUT', body: { catalogPath: form.catalogPath.trim(), autoSync: form.autoSync } }))
42+ apply(await request<Settings>('/api/settings', { method: 'PUT', body: { catalogPath: form.catalogPath.trim(), autoSync: form.autoSync }, silent: true }))
3443 await refreshCatalog()
3544 toast.add({ title: 'Settings saved', icon: 'i-lucide-check', color: 'success' })
36- } catch {
37- // toast shown by useApi
45+ } catch (e) {
46+ toast.add({ title: 'Settings not saved', description: (e as Error).message, color: 'error', icon: 'i-lucide-circle-alert' })
3847 } finally {
3948 saving.value = false
4049 }
4150 }
51+
52+const appearance = computed({
53+ get: () => colorMode.preference as 'light' | 'dark' | 'system',
54+ set: (v) => { colorMode.preference = v }
55+})
56+const APPEARANCE = [
57+ { value: 'light', label: 'Light' },
58+ { value: 'dark', label: 'Dark' },
59+ { value: 'system', label: 'System' }
60+] as const
4261 </script>
4362
4463 <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" />
64+ <SkPage>
65+ <SkPageHeader title="Settings" subtitle="Where the catalog lives and how it stays in sync." />
66+
67+ <SkNote v-if="error" tone="accent">Could not load the settings: {{ error }}</SkNote>
68+ <SkPanel v-else-if="!loaded" padded>
69+ <div class="flex flex-col gap-3"><USkeleton class="h-5 w-1/4" /><USkeleton class="h-9 w-full" /><USkeleton class="h-5 w-1/2" /></div>
70+ </SkPanel>
71+
72+ <template v-else>
73+ <SkPanel padded class="flex flex-col gap-5">
74+ <SkStepHeader title="Catalog" hint="A folder, usually a git repository, holding your skills, rules, MCP servers, hooks, subagents and presets." />
75+
76+ <div class="flex flex-col gap-1.5">
77+ <label class="font-mono text-[11px] tracking-[.06em] text-sk-faint uppercase" for="catalog-path">Catalog folder</label>
78+ <div class="flex gap-2">
79+ <UInput id="catalog-path" v-model="form.catalogPath" class="min-w-0 flex-1" :ui="{ base: 'font-mono' }" />
80+ <SkButton icon="i-lucide-folder-search" label="Browse" @click="pickerOpen = true" />
81+ </div>
82+ <p v-if="pathChanged" class="m-0 flex items-center gap-1.5 text-xs text-sk-muted">
83+ Saving re-opens the catalog at the new location. Nothing is moved or deleted in the old folder.
84+ </p>
85+ <p v-else-if="catalog" class="m-0 flex items-center gap-1.5 text-xs text-sk-muted">
86+ <SkStatusDot :tone="catalog.snapshot.exists ? 'ok' : 'faint'" />
87+ {{ catalog.snapshot.exists ? 'Catalog found at this location.' : 'No catalog at this location yet. Create it from the Catalog page, or restore one from a remote in the setup.' }}
88+ </p>
89+ </div>
90+
91+ <SkToggleRow v-model="form.autoSync" label="Automatic sync" hint="Commit, pull and push the catalog a few seconds after each change. Without a git remote, changes are only committed." />
92+
93+ <div class="flex items-center justify-end gap-2 border-t border-sk-line2 pt-4">
94+ <SkButton variant="ghost" label="Reset" :disabled="!dirty || saving" @click="apply(loaded!)" />
95+ <SkButton variant="primary" label="Save" :loading="saving" :disabled="!dirty || !form.catalogPath.trim()" @click="save" />
96+ </div>
97+ </SkPanel>
98+
99+ <SkPanel padded class="flex flex-col gap-3.5">
100+ <SkStepHeader title="Appearance" hint="Applies immediately; stored in this browser." />
101+ <div><SkSeg v-model="appearance" size="md" aria-label="Appearance" :options="[...APPEARANCE]" /></div>
102+ </SkPanel>
103+
104+ <SkPanel padded class="flex flex-col gap-3.5">
105+ <SkStepHeader title="Setup" hint="Walk through connecting a catalog and adding projects again. Existing data is kept." />
106+ <div><SkButton label="Run setup again" to="/onboarding" /></div>
107+ </SkPanel>
108+
109+ <SkPanel padded class="flex flex-col gap-3.5">
110+ <SkStepHeader title="About" />
111+ <SkMetaGrid label-width="130px">
112+ <SkMeta label="Version" mono>{{ version ?? '-' }}</SkMeta>
113+ <SkMeta label="Projects" mono>{{ projects.length }}</SkMeta>
114+ </SkMetaGrid>
115+ <p class="m-0 text-xs text-sk-faint">skilly runs locally. The catalog only leaves this machine through the git remote you set.</p>
116+ </SkPanel>
82117 </template>
83- </UDashboardPanel>
118+
119+ <FolderPickerModal v-model:open="pickerOpen" title="Choose the catalog folder" :start="form.catalogPath" @pick="(p: string) => form.catalogPath = p" />
120+ </SkPage>
84121 </template>
@@ -1,17 +1,22 @@
1 <script setup lang="ts">1 <script setup lang="ts">
2 import type { Settings } from '@skilly/core'2 import type { Settings } from '@skilly/core'
3 3
4+/** Settings (brief 3.1): catalog location + automatic sync, appearance, run the setup again, about. */
4 const { request } = useApi()5 const { request } = useApi()
5 const { refresh: refreshCatalog, data: catalog } = useCatalog()6 const { refresh: refreshCatalog, data: catalog } = useCatalog()
7+const { projects } = useProjects()
6 const toast = useToast()8 const toast = useToast()
9+const colorMode = useColorMode()
7 10
8 const loaded = ref<Settings | null>(null)11 const loaded = ref<Settings | null>(null)
9 const form = reactive<Settings>({ catalogPath: '', autoSync: false })12 const form = reactive<Settings>({ catalogPath: '', autoSync: false })
10 const error = ref<string | null>(null)13 const error = ref<string | null>(null)
11 const saving = ref(false)14 const saving = ref(false)
12 const pickerOpen = ref(false)15 const pickerOpen = ref(false)
16+const version = ref<string | null>(null)
13 17
14-const dirty = computed(() => !!loaded.value && (form.catalogPath !== loaded.value.catalogPath || form.autoSync !== loaded.value.autoSync))18+const dirty = computed(() => !!loaded.value && (form.catalogPath.trim() !== loaded.value.catalogPath || form.autoSync !== loaded.value.autoSync))
19+const pathChanged = computed(() => !!loaded.value && form.catalogPath.trim() !== loaded.value.catalogPath)
15 20
16 function apply(s: Settings) {21 function apply(s: Settings) {
17 loaded.value = s22 loaded.value = s
@@ -25,60 +30,92 @@ onMounted(async () => {
25 } catch (e) {30 } catch (e) {
26 error.value = (e as Error).message31 error.value = (e as Error).message
27 }32 }
33+ if (!catalog.value) refreshCatalog()
34+ try {
35+ version.value = (await request<{ version: string }>('/api/health', { silent: true })).version
36+ } catch { /* the version line just stays hidden */ }
28 })37 })
29 38
30 async function save() {39 async function save() {
31 saving.value = true40 saving.value = true
32 try {41 try {
33- apply(await request<Settings>('/api/settings', { method: 'PUT', body: { catalogPath: form.catalogPath.trim(), autoSync: form.autoSync } }))42+ apply(await request<Settings>('/api/settings', { method: 'PUT', body: { catalogPath: form.catalogPath.trim(), autoSync: form.autoSync }, silent: true }))
34 await refreshCatalog()43 await refreshCatalog()
35 toast.add({ title: 'Settings saved', icon: 'i-lucide-check', color: 'success' })44 toast.add({ title: 'Settings saved', icon: 'i-lucide-check', color: 'success' })
36- } catch {45+ } catch (e) {
37- // toast shown by useApi46+ toast.add({ title: 'Settings not saved', description: (e as Error).message, color: 'error', icon: 'i-lucide-circle-alert' })
38 } finally {47 } finally {
39 saving.value = false48 saving.value = false
40 }49 }
41 }50 }
51+
52+const appearance = computed({
53+ get: () => colorMode.preference as 'light' | 'dark' | 'system',
54+ set: (v) => { colorMode.preference = v }
55+})
56+const APPEARANCE = [
57+ { value: 'light', label: 'Light' },
58+ { value: 'dark', label: 'Dark' },
59+ { value: 'system', label: 'System' }
60+] as const
42 </script>61 </script>
43 62
44 <template>63 <template>
45- <UDashboardPanel id="settings">64+ <SkPage>
46- <template #header>65+ <SkPageHeader title="Settings" subtitle="Where the catalog lives and how it stays in sync." />
47- <UDashboardNavbar title="Settings" icon="i-lucide-settings" />66+
48- </template>67+ <SkNote v-if="error" tone="accent">Could not load the settings: {{ error }}</SkNote>
49- <template #body>68+ <SkPanel v-else-if="!loaded" padded>
50- <div class="mx-auto w-full max-w-2xl space-y-6">69+ <div class="flex flex-col gap-3"><USkeleton class="h-5 w-1/4" /><USkeleton class="h-9 w-full" /><USkeleton class="h-5 w-1/2" /></div>
51- <UAlert v-if="error" color="error" variant="subtle" icon="i-lucide-circle-alert" title="Could not load settings" :description="error" />70+ </SkPanel>
52- <USkeleton v-else-if="!loaded" class="h-40 w-full" />71+
53- <template v-else>72+ <template v-else>
54- <UCard>73+ <SkPanel padded class="flex flex-col gap-5">
55- <template #header>74+ <SkStepHeader title="Catalog" hint="A folder, usually a git repository, holding your skills, rules, MCP servers, hooks, subagents and presets." />
56- <h2 class="font-semibold">Catalog</h2>75+
57- </template>76+ <div class="flex flex-col gap-1.5">
58- <div class="space-y-4">77+ <label class="font-mono text-[11px] tracking-[.06em] text-sk-faint uppercase" for="catalog-path">Catalog folder</label>
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.">78+ <div class="flex gap-2">
60- <div class="flex gap-2">79+ <UInput id="catalog-path" v-model="form.catalogPath" class="min-w-0 flex-1" :ui="{ base: 'font-mono' }" />
61- <UInput v-model="form.catalogPath" class="flex-1" :ui="{ base: 'font-mono' }" />80+ <SkButton icon="i-lucide-folder-search" label="Browse" @click="pickerOpen = true" />
62- <UButton label="Browse" icon="i-lucide-folder-search" color="neutral" variant="outline" @click="pickerOpen = true" />81+ </div>
63- </div>82+ <p v-if="pathChanged" class="m-0 flex items-center gap-1.5 text-xs text-sk-muted">
64- </UFormField>83+ Saving re-opens the catalog at the new location. Nothing is moved or deleted in the old folder.
65- <p v-if="catalog && form.catalogPath === loaded.catalogPath" class="text-xs" :class="catalog.snapshot.exists ? 'text-success' : 'text-warning'">84+ </p>
66- {{ catalog.snapshot.exists ? 'Catalog found at this location.' : 'No catalog at this location yet. Create it from the Catalog page.' }}85+ <p v-else-if="catalog" class="m-0 flex items-center gap-1.5 text-xs text-sk-muted">
67- </p>86+ <SkStatusDot :tone="catalog.snapshot.exists ? 'ok' : 'faint'" />
68- <p v-else-if="dirty" class="text-xs text-muted">Saving re-opens the catalog at the new location.</p>87+ {{ catalog.snapshot.exists ? 'Catalog found at this location.' : 'No catalog at this location yet. Create it from the Catalog page, or restore one from a remote in the setup.' }}
69-88+ </p>
70- <USwitch v-model="form.autoSync" label="Automatic sync" description="Commit, pull and push the catalog automatically after changes (needs a git remote)." />89+ </div>
71- </div>90+
72- <template #footer>91+ <SkToggleRow v-model="form.autoSync" label="Automatic sync" hint="Commit, pull and push the catalog a few seconds after each change. Without a git remote, changes are only committed." />
73- <div class="flex justify-end gap-2">92+
74- <UButton label="Reset" color="neutral" variant="ghost" :disabled="!dirty" @click="apply(loaded!)" />93+ <div class="flex items-center justify-end gap-2 border-t border-sk-line2 pt-4">
75- <UButton label="Save" icon="i-lucide-save" :loading="saving" :disabled="!dirty || !form.catalogPath.trim()" @click="save" />94+ <SkButton variant="ghost" label="Reset" :disabled="!dirty || saving" @click="apply(loaded!)" />
76- </div>95+ <SkButton variant="primary" label="Save" :loading="saving" :disabled="!dirty || !form.catalogPath.trim()" @click="save" />
77- </template>96+ </div>
78- </UCard>97+ </SkPanel>
79- </template>98+
80- </div>99+ <SkPanel padded class="flex flex-col gap-3.5">
81- <FolderPickerModal v-model:open="pickerOpen" title="Choose the catalog folder" :start="form.catalogPath" @pick="(p) => form.catalogPath = p" />100+ <SkStepHeader title="Appearance" hint="Applies immediately; stored in this browser." />
101+ <div><SkSeg v-model="appearance" size="md" aria-label="Appearance" :options="[...APPEARANCE]" /></div>
102+ </SkPanel>
103+
104+ <SkPanel padded class="flex flex-col gap-3.5">
105+ <SkStepHeader title="Setup" hint="Walk through connecting a catalog and adding projects again. Existing data is kept." />
106+ <div><SkButton label="Run setup again" to="/onboarding" /></div>
107+ </SkPanel>
108+
109+ <SkPanel padded class="flex flex-col gap-3.5">
110+ <SkStepHeader title="About" />
111+ <SkMetaGrid label-width="130px">
112+ <SkMeta label="Version" mono>{{ version ?? '-' }}</SkMeta>
113+ <SkMeta label="Projects" mono>{{ projects.length }}</SkMeta>
114+ </SkMetaGrid>
115+ <p class="m-0 text-xs text-sk-faint">skilly runs locally. The catalog only leaves this machine through the git remote you set.</p>
116+ </SkPanel>
82 </template>117 </template>
83- </UDashboardPanel>118+
119+ <FolderPickerModal v-model:open="pickerOpen" title="Choose the catalog folder" :start="form.catalogPath" @pick="(p: string) => form.catalogPath = p" />
120+ </SkPage>
84 </template>121 </template>
modified packages/ui/app/pages/sync.vue +244 -10
@@ -1,20 +1,254 @@
11 <script setup lang="ts">
2-// PLACEHOLDER (WP3 owns this page). Until the real Sync page exists, the old git dialog is the way to manage the
3-// catalog repository; it is opened from here so the header pill always leads somewhere useful.
4-const git = useCatalogGit()
5-const { git: status } = useCatalog()
2+import type { Settings } from '@skilly/core'
63
7-onMounted(() => { git.open.value = true })
4+/**
5+ * Sync (brief 3.1): the catalog git repository in one page. Status, remote, commit, pull / push / sync, conflicts
6+ * resolved item by item, automatic sync. The header pill (`gitPillState`) leads here.
7+ */
8+const { request } = useApi()
9+const { git, exists, data, refresh, init } = useCatalog()
10+const { busy, error, run } = useCatalogGit()
11+const toast = useToast()
12+
13+const settings = ref<Settings | null>(null)
14+const autoSyncSaving = ref(false)
15+const remoteInput = ref('')
16+const message = ref('')
17+const initialising = ref(false)
18+const resolving = ref<string | null>(null)
19+
20+async function load() {
21+ await refresh()
22+ try {
23+ settings.value = await request<Settings>('/api/settings', { silent: true })
24+ } catch { /* the page works without it; the switch stays disabled */ }
25+}
26+onMounted(() => {
27+ error.value = null
28+ load()
29+})
30+watch(() => git.value?.remote, (r) => { remoteInput.value = r ?? '' }, { immediate: true })
31+
32+const dirty = computed(() => git.value?.dirty ?? [])
33+const conflicts = computed(() => git.value?.conflicts ?? [])
34+const groups = computed(() => groupConflicts(conflicts.value))
35+const hasRemote = computed(() => !!git.value?.remote)
36+const working = computed(() => !!busy.value || !!resolving.value)
37+
38+/** Big number of the status tile: what needs attention first. */
39+const tile = computed(() => {
40+ const g = git.value
41+ if (!g) return { value: '-', caption: '', variant: 'blue' as const }
42+ if (g.conflicts.length) {
43+ const n = groups.value.length
44+ return { value: g.conflicts.length, caption: `conflicting file${g.conflicts.length === 1 ? '' : 's'} in ${n} item${n === 1 ? '' : 's'}`, variant: 'clay' as const }
45+ }
46+ if (g.behind || g.ahead) {
47+ const parts = [g.ahead ? `${g.ahead} to push` : '', g.behind ? `${g.behind} to pull` : ''].filter(Boolean)
48+ return { value: g.ahead + g.behind, caption: parts.join(' · '), variant: 'blue' as const }
49+ }
50+ if (g.dirty.length) return { value: g.dirty.length, caption: `uncommitted change${g.dirty.length === 1 ? '' : 's'}`, variant: 'blue' as const }
51+ return { value: 0, caption: g.remote ? 'up to date' : 'nothing to commit', variant: 'blue' as const }
52+})
53+
54+const subtitle = computed(() => {
55+ const g = git.value
56+ if (!g?.isRepo) return 'Catalog repository'
57+ return ['Catalog repository', g.remote ?? 'no remote', g.branch].filter(Boolean).join(' · ')
58+})
59+
60+const BUSY_LABELS: Record<string, string> = {
61+ sync: 'Committing, pulling and pushing the catalog…',
62+ pull: 'Pulling from the remote…',
63+ push: 'Pushing to the remote…',
64+ commit: 'Committing…',
65+ remote: 'Updating the remote…',
66+ resolve: 'Resolving…'
67+}
68+const busyLabel = computed(() => BUSY_LABELS[busy.value ?? 'sync'])
69+
70+async function setRemote() {
71+ await run('remote', { url: remoteInput.value.trim() }, 'Remote updated')
72+}
73+
74+async function commit() {
75+ if (await run('commit', { message: message.value.trim() }, 'Committed')) message.value = ''
76+}
77+
78+async function resolveGroup(key: string, side: 'ours' | 'theirs') {
79+ const group = groups.value.find(g => g.key === key)
80+ if (!group) return
81+ resolving.value = key
82+ try {
83+ // One request per path: the last one commits the merge once no conflict remains.
84+ for (const path of [...group.paths]) {
85+ if (!(await run('resolve', { path, side }))) return
86+ }
87+ toast.add({ title: `${group.name} resolved`, description: side === 'ours' ? 'Kept your version.' : 'Used the remote version.', icon: 'i-lucide-check', color: 'success' })
88+ } finally {
89+ resolving.value = null
90+ }
91+}
92+
93+async function initRepo() {
94+ initialising.value = true
95+ try {
96+ await init()
97+ } catch (e) {
98+ error.value = (e as Error).message
99+ } finally {
100+ initialising.value = false
101+ }
102+}
103+
104+async function setAutoSync(v: boolean) {
105+ if (!settings.value) return
106+ const previous = settings.value
107+ settings.value = { ...previous, autoSync: v }
108+ autoSyncSaving.value = true
109+ try {
110+ settings.value = await request<Settings>('/api/settings', { method: 'PUT', body: { autoSync: v }, silent: true })
111+ } catch (e) {
112+ settings.value = previous
113+ error.value = (e as Error).message
114+ } finally {
115+ autoSyncSaving.value = false
116+ }
117+}
118+
119+const noRemoteHint = 'Set a remote first'
8120 </script>
9121
10122 <template>
11123 <SkPage>
12- <SkPageHeader title="Sync" :subtitle="status?.remote ? `Catalog repository · ${status.remote}${status.branch ? ' · ' + status.branch : ''}` : 'Catalog repository'" />
13- <SkPanel padded class="flex flex-col items-start gap-3">
14- <SkEmpty text="Not designed yet. The catalog git dialog is still the way to commit, pull and push." class="self-stretch" />
15- <div class="flex w-full justify-center">
16- <SkButton variant="primary" label="Open git dialog" @click="git.open.value = true" />
124+ <SkPageHeader title="Sync" :subtitle="subtitle">
125+ <template #actions>
126+ <SkButton variant="ghost" icon="i-lucide-refresh-cw" label="Refresh" :disabled="working" @click="load" />
127+ </template>
128+ </SkPageHeader>
129+
130+ <SkNote v-if="error" tone="accent">
131+ <div class="flex items-start gap-3">
132+ <div class="min-w-0 flex-1">
133+ <p class="m-0 font-semibold">Git error</p>
134+ <p class="m-0 mt-0.5 font-mono text-xs break-words whitespace-pre-wrap">{{ error }}</p>
135+ </div>
136+ <SkButton size="xs" variant="ghost" label="Dismiss" @click="error = null" />
17137 </div>
138+ </SkNote>
139+
140+ <SkNote v-if="busy" tone="info">{{ busyLabel }} <span class="text-sk-muted">Large catalogs can take a moment.</span></SkNote>
141+
142+ <SkPanel v-if="!data" padded>
143+ <div class="flex flex-col gap-3"><USkeleton class="h-6 w-1/3" /><USkeleton class="h-40 w-full" /></div>
144+ </SkPanel>
145+
146+ <SkPanel v-else-if="!exists" padded>
147+ <SkEmpty title="No catalog yet" text="The catalog folder has not been created, so there is nothing to sync.">
148+ <template #actions>
149+ <SkButton variant="primary" label="Open the catalog" to="/catalog" />
150+ <SkButton label="Settings" to="/settings" />
151+ </template>
152+ </SkEmpty>
153+ </SkPanel>
154+
155+ <SkPanel v-else-if="!git?.isRepo" padded>
156+ <SkEmpty title="Not a git repository" text="The catalog folder is not versioned yet. Initialize git to commit it and share it through a remote.">
157+ <template #actions>
158+ <SkButton variant="primary" label="Initialize git" :loading="initialising" @click="initRepo" />
159+ </template>
160+ </SkEmpty>
18161 </SkPanel>
162+
163+ <template v-else-if="git">
164+ <!-- 1 Status -->
165+ <SkPanel padded class="flex flex-col gap-4">
166+ <SkStepHeader step="1" title="Status" :hint="git.conflicts.length ? 'The last pull stopped on conflicts.' : hasRemote ? 'Local catalog compared to the remote.' : 'This catalog has no remote yet.'" />
167+ <div class="grid items-stretch gap-[22px] md:grid-cols-[280px_minmax(0,1fr)]">
168+ <SkDitherTile :variant="tile.variant" :height="188" :value="tile.value" :caption="tile.caption" />
169+ <div class="flex min-w-0 flex-col justify-center gap-3.5">
170+ <SkMetaGrid label-width="130px">
171+ <SkMeta label="Branch" mono>{{ git.branch ?? '-' }}</SkMeta>
172+ <SkMeta label="Remote">
173+ <form class="flex flex-wrap items-center gap-2" @submit.prevent="setRemote">
174+ <UInput
175+ v-model="remoteInput"
176+ placeholder="git@github.com:you/agent-catalog.git"
177+ aria-label="Remote URL"
178+ class="min-w-0 flex-1 basis-60"
179+ :ui="{ base: 'font-mono' }"
180+ />
181+ <SkButton type="submit" label="Set remote" :loading="busy === 'remote'" :disabled="working || !remoteInput.trim() || remoteInput.trim() === git.remote" />
182+ </form>
183+ <p class="m-0 mt-1 text-xs text-sk-faint">GitHub, GitLab or self-hosted. The catalog never stores secrets.</p>
184+ </SkMeta>
185+ <SkMeta label="Last commit">
186+ <span v-if="git.lastCommit" class="flex min-w-0 flex-wrap items-baseline gap-x-2">
187+ <span class="font-mono text-xs text-sk-faint">{{ git.lastCommit.hash.slice(0, 7) }}</span>
188+ <span class="min-w-0 truncate" :title="git.lastCommit.message">{{ git.lastCommit.message }}</span>
189+ <span class="text-xs text-sk-faint" :title="formatDate(git.lastCommit.date)">{{ timeAgo(git.lastCommit.date) }}</span>
190+ </span>
191+ <span v-else class="text-sk-faint">No commit yet</span>
192+ </SkMeta>
193+ <SkMeta label="Automatic sync">
194+ <SkToggleRow
195+ :model-value="settings?.autoSync ?? false"
196+ :disabled="!settings || autoSyncSaving"
197+ label="Commit, pull and push after changes"
198+ :hint="hasRemote ? 'Runs a few seconds after each catalog change.' : 'Without a remote, changes are only committed locally.'"
199+ @update:model-value="setAutoSync"
200+ />
201+ </SkMeta>
202+ </SkMetaGrid>
203+ </div>
204+ </div>
205+ </SkPanel>
206+
207+ <!-- 2 Conflicts -->
208+ <SkPanel v-if="groups.length" padded class="flex flex-col gap-3">
209+ <SkStepHeader step="2" title="Conflicts" hint="Changed on both sides. Choose a version for each item; nothing else is blocked." />
210+ <SkTray>
211+ <SkRowCard v-for="g in groups" :key="g.key" cols="minmax(0,1fr) auto">
212+ <div class="min-w-0">
213+ <div class="flex items-baseline gap-2">
214+ <span class="truncate font-medium">{{ g.name }}</span>
215+ <span v-if="g.type" class="font-mono text-[11px] text-sk-faint">{{ g.type }}</span>
216+ </div>
217+ <ul class="m-0 mt-0.5 list-none p-0">
218+ <li v-for="p in g.paths" :key="p" class="truncate font-mono text-[11px] text-sk-faint" :title="p">{{ p }}</li>
219+ </ul>
220+ </div>
221+ <div class="flex items-center gap-2">
222+ <SkButton size="sm" label="Keep mine" :loading="resolving === g.key" :disabled="working" @click="resolveGroup(g.key, 'ours')" />
223+ <SkButton size="sm" label="Use remote" :disabled="working" @click="resolveGroup(g.key, 'theirs')" />
224+ </div>
225+ </SkRowCard>
226+ </SkTray>
227+ <p class="m-0 text-xs text-sk-muted">Keep mine keeps the version on this machine. Use remote takes the version from the remote. Once every conflict is resolved, skilly commits the merge.</p>
228+ </SkPanel>
229+
230+ <!-- Changes -->
231+ <SkPanel padded class="flex flex-col gap-3.5">
232+ <SkStepHeader :step="groups.length ? '3' : '2'" title="Changes" :hint="dirty.length ? `${dirty.length} uncommitted file${dirty.length === 1 ? '' : 's'}.` : 'Working tree clean.'" />
233+
234+ <SkTray v-if="dirty.length" class="max-h-64 overflow-auto">
235+ <SkRowCard v-for="d in dirty" :key="d" class="truncate font-mono text-xs" :title="d">{{ d }}</SkRowCard>
236+ </SkTray>
237+ <SkEmpty v-else text="Nothing to commit." class="py-5" />
238+
239+ <form class="flex flex-wrap items-center gap-2" @submit.prevent="commit">
240+ <UInput v-model="message" placeholder="Commit message" aria-label="Commit message" class="min-w-0 flex-1 basis-64" :disabled="!dirty.length || working" />
241+ <SkButton type="submit" variant="primary" label="Commit" :loading="busy === 'commit'" :disabled="!dirty.length || !message.trim() || working || !!conflicts.length" />
242+ </form>
243+
244+ <div class="flex flex-wrap items-center gap-2 border-t border-sk-line2 pt-3.5">
245+ <SkButton icon="i-lucide-arrow-down" label="Pull" :loading="busy === 'pull'" :disabled="!hasRemote || working" :title="hasRemote ? undefined : noRemoteHint" @click="run('pull', undefined, 'Pulled')" />
246+ <SkButton icon="i-lucide-arrow-up" label="Push" :loading="busy === 'push'" :disabled="!hasRemote || working || !!conflicts.length" :title="hasRemote ? undefined : noRemoteHint" @click="run('push', undefined, 'Pushed')" />
247+ <span class="flex-1" />
248+ <span v-if="!hasRemote" class="text-xs text-sk-faint">Set a remote to pull and push.</span>
249+ <SkButton variant="primary" icon="i-lucide-refresh-cw" label="Pull & push" :loading="busy === 'sync'" :disabled="!hasRemote || working || !!conflicts.length" :title="hasRemote ? 'Commits everything, pulls, then pushes' : noRemoteHint" @click="run('sync', undefined, 'Catalog synced')" />
250+ </div>
251+ </SkPanel>
252+ </template>
19253 </SkPage>
20254 </template>
@@ -1,20 +1,254 @@
1 <script setup lang="ts">1 <script setup lang="ts">
2-// PLACEHOLDER (WP3 owns this page). Until the real Sync page exists, the old git dialog is the way to manage the2+import type { Settings } from '@skilly/core'
3-// catalog repository; it is opened from here so the header pill always leads somewhere useful.
4-const git = useCatalogGit()
5-const { git: status } = useCatalog()
6 3
7-onMounted(() => { git.open.value = true })4+/**
5+ * Sync (brief 3.1): the catalog git repository in one page. Status, remote, commit, pull / push / sync, conflicts
6+ * resolved item by item, automatic sync. The header pill (`gitPillState`) leads here.
7+ */
8+const { request } = useApi()
9+const { git, exists, data, refresh, init } = useCatalog()
10+const { busy, error, run } = useCatalogGit()
11+const toast = useToast()
12+
13+const settings = ref<Settings | null>(null)
14+const autoSyncSaving = ref(false)
15+const remoteInput = ref('')
16+const message = ref('')
17+const initialising = ref(false)
18+const resolving = ref<string | null>(null)
19+
20+async function load() {
21+ await refresh()
22+ try {
23+ settings.value = await request<Settings>('/api/settings', { silent: true })
24+ } catch { /* the page works without it; the switch stays disabled */ }
25+}
26+onMounted(() => {
27+ error.value = null
28+ load()
29+})
30+watch(() => git.value?.remote, (r) => { remoteInput.value = r ?? '' }, { immediate: true })
31+
32+const dirty = computed(() => git.value?.dirty ?? [])
33+const conflicts = computed(() => git.value?.conflicts ?? [])
34+const groups = computed(() => groupConflicts(conflicts.value))
35+const hasRemote = computed(() => !!git.value?.remote)
36+const working = computed(() => !!busy.value || !!resolving.value)
37+
38+/** Big number of the status tile: what needs attention first. */
39+const tile = computed(() => {
40+ const g = git.value
41+ if (!g) return { value: '-', caption: '', variant: 'blue' as const }
42+ if (g.conflicts.length) {
43+ const n = groups.value.length
44+ return { value: g.conflicts.length, caption: `conflicting file${g.conflicts.length === 1 ? '' : 's'} in ${n} item${n === 1 ? '' : 's'}`, variant: 'clay' as const }
45+ }
46+ if (g.behind || g.ahead) {
47+ const parts = [g.ahead ? `${g.ahead} to push` : '', g.behind ? `${g.behind} to pull` : ''].filter(Boolean)
48+ return { value: g.ahead + g.behind, caption: parts.join(' · '), variant: 'blue' as const }
49+ }
50+ if (g.dirty.length) return { value: g.dirty.length, caption: `uncommitted change${g.dirty.length === 1 ? '' : 's'}`, variant: 'blue' as const }
51+ return { value: 0, caption: g.remote ? 'up to date' : 'nothing to commit', variant: 'blue' as const }
52+})
53+
54+const subtitle = computed(() => {
55+ const g = git.value
56+ if (!g?.isRepo) return 'Catalog repository'
57+ return ['Catalog repository', g.remote ?? 'no remote', g.branch].filter(Boolean).join(' · ')
58+})
59+
60+const BUSY_LABELS: Record<string, string> = {
61+ sync: 'Committing, pulling and pushing the catalog…',
62+ pull: 'Pulling from the remote…',
63+ push: 'Pushing to the remote…',
64+ commit: 'Committing…',
65+ remote: 'Updating the remote…',
66+ resolve: 'Resolving…'
67+}
68+const busyLabel = computed(() => BUSY_LABELS[busy.value ?? 'sync'])
69+
70+async function setRemote() {
71+ await run('remote', { url: remoteInput.value.trim() }, 'Remote updated')
72+}
73+
74+async function commit() {
75+ if (await run('commit', { message: message.value.trim() }, 'Committed')) message.value = ''
76+}
77+
78+async function resolveGroup(key: string, side: 'ours' | 'theirs') {
79+ const group = groups.value.find(g => g.key === key)
80+ if (!group) return
81+ resolving.value = key
82+ try {
83+ // One request per path: the last one commits the merge once no conflict remains.
84+ for (const path of [...group.paths]) {
85+ if (!(await run('resolve', { path, side }))) return
86+ }
87+ toast.add({ title: `${group.name} resolved`, description: side === 'ours' ? 'Kept your version.' : 'Used the remote version.', icon: 'i-lucide-check', color: 'success' })
88+ } finally {
89+ resolving.value = null
90+ }
91+}
92+
93+async function initRepo() {
94+ initialising.value = true
95+ try {
96+ await init()
97+ } catch (e) {
98+ error.value = (e as Error).message
99+ } finally {
100+ initialising.value = false
101+ }
102+}
103+
104+async function setAutoSync(v: boolean) {
105+ if (!settings.value) return
106+ const previous = settings.value
107+ settings.value = { ...previous, autoSync: v }
108+ autoSyncSaving.value = true
109+ try {
110+ settings.value = await request<Settings>('/api/settings', { method: 'PUT', body: { autoSync: v }, silent: true })
111+ } catch (e) {
112+ settings.value = previous
113+ error.value = (e as Error).message
114+ } finally {
115+ autoSyncSaving.value = false
116+ }
117+}
118+
119+const noRemoteHint = 'Set a remote first'
8 </script>120 </script>
9 121
10 <template>122 <template>
11 <SkPage>123 <SkPage>
12- <SkPageHeader title="Sync" :subtitle="status?.remote ? `Catalog repository · ${status.remote}${status.branch ? ' · ' + status.branch : ''}` : 'Catalog repository'" />124+ <SkPageHeader title="Sync" :subtitle="subtitle">
13- <SkPanel padded class="flex flex-col items-start gap-3">125+ <template #actions>
14- <SkEmpty text="Not designed yet. The catalog git dialog is still the way to commit, pull and push." class="self-stretch" />126+ <SkButton variant="ghost" icon="i-lucide-refresh-cw" label="Refresh" :disabled="working" @click="load" />
15- <div class="flex w-full justify-center">127+ </template>
16- <SkButton variant="primary" label="Open git dialog" @click="git.open.value = true" />128+ </SkPageHeader>
129+
130+ <SkNote v-if="error" tone="accent">
131+ <div class="flex items-start gap-3">
132+ <div class="min-w-0 flex-1">
133+ <p class="m-0 font-semibold">Git error</p>
134+ <p class="m-0 mt-0.5 font-mono text-xs break-words whitespace-pre-wrap">{{ error }}</p>
135+ </div>
136+ <SkButton size="xs" variant="ghost" label="Dismiss" @click="error = null" />
17 </div>137 </div>
138+ </SkNote>
139+
140+ <SkNote v-if="busy" tone="info">{{ busyLabel }} <span class="text-sk-muted">Large catalogs can take a moment.</span></SkNote>
141+
142+ <SkPanel v-if="!data" padded>
143+ <div class="flex flex-col gap-3"><USkeleton class="h-6 w-1/3" /><USkeleton class="h-40 w-full" /></div>
144+ </SkPanel>
145+
146+ <SkPanel v-else-if="!exists" padded>
147+ <SkEmpty title="No catalog yet" text="The catalog folder has not been created, so there is nothing to sync.">
148+ <template #actions>
149+ <SkButton variant="primary" label="Open the catalog" to="/catalog" />
150+ <SkButton label="Settings" to="/settings" />
151+ </template>
152+ </SkEmpty>
153+ </SkPanel>
154+
155+ <SkPanel v-else-if="!git?.isRepo" padded>
156+ <SkEmpty title="Not a git repository" text="The catalog folder is not versioned yet. Initialize git to commit it and share it through a remote.">
157+ <template #actions>
158+ <SkButton variant="primary" label="Initialize git" :loading="initialising" @click="initRepo" />
159+ </template>
160+ </SkEmpty>
18 </SkPanel>161 </SkPanel>
162+
163+ <template v-else-if="git">
164+ <!-- 1 Status -->
165+ <SkPanel padded class="flex flex-col gap-4">
166+ <SkStepHeader step="1" title="Status" :hint="git.conflicts.length ? 'The last pull stopped on conflicts.' : hasRemote ? 'Local catalog compared to the remote.' : 'This catalog has no remote yet.'" />
167+ <div class="grid items-stretch gap-[22px] md:grid-cols-[280px_minmax(0,1fr)]">
168+ <SkDitherTile :variant="tile.variant" :height="188" :value="tile.value" :caption="tile.caption" />
169+ <div class="flex min-w-0 flex-col justify-center gap-3.5">
170+ <SkMetaGrid label-width="130px">
171+ <SkMeta label="Branch" mono>{{ git.branch ?? '-' }}</SkMeta>
172+ <SkMeta label="Remote">
173+ <form class="flex flex-wrap items-center gap-2" @submit.prevent="setRemote">
174+ <UInput
175+ v-model="remoteInput"
176+ placeholder="git@github.com:you/agent-catalog.git"
177+ aria-label="Remote URL"
178+ class="min-w-0 flex-1 basis-60"
179+ :ui="{ base: 'font-mono' }"
180+ />
181+ <SkButton type="submit" label="Set remote" :loading="busy === 'remote'" :disabled="working || !remoteInput.trim() || remoteInput.trim() === git.remote" />
182+ </form>
183+ <p class="m-0 mt-1 text-xs text-sk-faint">GitHub, GitLab or self-hosted. The catalog never stores secrets.</p>
184+ </SkMeta>
185+ <SkMeta label="Last commit">
186+ <span v-if="git.lastCommit" class="flex min-w-0 flex-wrap items-baseline gap-x-2">
187+ <span class="font-mono text-xs text-sk-faint">{{ git.lastCommit.hash.slice(0, 7) }}</span>
188+ <span class="min-w-0 truncate" :title="git.lastCommit.message">{{ git.lastCommit.message }}</span>
189+ <span class="text-xs text-sk-faint" :title="formatDate(git.lastCommit.date)">{{ timeAgo(git.lastCommit.date) }}</span>
190+ </span>
191+ <span v-else class="text-sk-faint">No commit yet</span>
192+ </SkMeta>
193+ <SkMeta label="Automatic sync">
194+ <SkToggleRow
195+ :model-value="settings?.autoSync ?? false"
196+ :disabled="!settings || autoSyncSaving"
197+ label="Commit, pull and push after changes"
198+ :hint="hasRemote ? 'Runs a few seconds after each catalog change.' : 'Without a remote, changes are only committed locally.'"
199+ @update:model-value="setAutoSync"
200+ />
201+ </SkMeta>
202+ </SkMetaGrid>
203+ </div>
204+ </div>
205+ </SkPanel>
206+
207+ <!-- 2 Conflicts -->
208+ <SkPanel v-if="groups.length" padded class="flex flex-col gap-3">
209+ <SkStepHeader step="2" title="Conflicts" hint="Changed on both sides. Choose a version for each item; nothing else is blocked." />
210+ <SkTray>
211+ <SkRowCard v-for="g in groups" :key="g.key" cols="minmax(0,1fr) auto">
212+ <div class="min-w-0">
213+ <div class="flex items-baseline gap-2">
214+ <span class="truncate font-medium">{{ g.name }}</span>
215+ <span v-if="g.type" class="font-mono text-[11px] text-sk-faint">{{ g.type }}</span>
216+ </div>
217+ <ul class="m-0 mt-0.5 list-none p-0">
218+ <li v-for="p in g.paths" :key="p" class="truncate font-mono text-[11px] text-sk-faint" :title="p">{{ p }}</li>
219+ </ul>
220+ </div>
221+ <div class="flex items-center gap-2">
222+ <SkButton size="sm" label="Keep mine" :loading="resolving === g.key" :disabled="working" @click="resolveGroup(g.key, 'ours')" />
223+ <SkButton size="sm" label="Use remote" :disabled="working" @click="resolveGroup(g.key, 'theirs')" />
224+ </div>
225+ </SkRowCard>
226+ </SkTray>
227+ <p class="m-0 text-xs text-sk-muted">Keep mine keeps the version on this machine. Use remote takes the version from the remote. Once every conflict is resolved, skilly commits the merge.</p>
228+ </SkPanel>
229+
230+ <!-- Changes -->
231+ <SkPanel padded class="flex flex-col gap-3.5">
232+ <SkStepHeader :step="groups.length ? '3' : '2'" title="Changes" :hint="dirty.length ? `${dirty.length} uncommitted file${dirty.length === 1 ? '' : 's'}.` : 'Working tree clean.'" />
233+
234+ <SkTray v-if="dirty.length" class="max-h-64 overflow-auto">
235+ <SkRowCard v-for="d in dirty" :key="d" class="truncate font-mono text-xs" :title="d">{{ d }}</SkRowCard>
236+ </SkTray>
237+ <SkEmpty v-else text="Nothing to commit." class="py-5" />
238+
239+ <form class="flex flex-wrap items-center gap-2" @submit.prevent="commit">
240+ <UInput v-model="message" placeholder="Commit message" aria-label="Commit message" class="min-w-0 flex-1 basis-64" :disabled="!dirty.length || working" />
241+ <SkButton type="submit" variant="primary" label="Commit" :loading="busy === 'commit'" :disabled="!dirty.length || !message.trim() || working || !!conflicts.length" />
242+ </form>
243+
244+ <div class="flex flex-wrap items-center gap-2 border-t border-sk-line2 pt-3.5">
245+ <SkButton icon="i-lucide-arrow-down" label="Pull" :loading="busy === 'pull'" :disabled="!hasRemote || working" :title="hasRemote ? undefined : noRemoteHint" @click="run('pull', undefined, 'Pulled')" />
246+ <SkButton icon="i-lucide-arrow-up" label="Push" :loading="busy === 'push'" :disabled="!hasRemote || working || !!conflicts.length" :title="hasRemote ? undefined : noRemoteHint" @click="run('push', undefined, 'Pushed')" />
247+ <span class="flex-1" />
248+ <span v-if="!hasRemote" class="text-xs text-sk-faint">Set a remote to pull and push.</span>
249+ <SkButton variant="primary" icon="i-lucide-refresh-cw" label="Pull & push" :loading="busy === 'sync'" :disabled="!hasRemote || working || !!conflicts.length" :title="hasRemote ? 'Commits everything, pulls, then pushes' : noRemoteHint" @click="run('sync', undefined, 'Catalog synced')" />
250+ </div>
251+ </SkPanel>
252+ </template>
19 </SkPage>253 </SkPage>
20 </template>254 </template>
modified packages/ui/app/utils/catalog.ts +92 -11
@@ -1,13 +1,17 @@
11 import type { CatalogItemType, HookTemplate, ItemStatus, McpEnvValue, McpTemplate } from '@skilly/core'
2+import { splitFrontmatter } from './markdown'
23
34 export const CAT_TYPES: CatalogItemType[] = ['skill', 'rule', 'agent', 'mcp', 'hook']
45
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 }
6+/** Display order in the UI (as in the design: skills, rules, MCP servers, hooks, subagents). */
7+export const CAT_ORDER: CatalogItemType[] = ['skill', 'rule', 'mcp', 'hook', 'agent']
8+
9+export const CAT_META: Record<CatalogItemType, { label: string, singular: string, newLabel: string, icon: string, markdown: boolean }> = {
10+ skill: { label: 'Skills', singular: 'Skill', newLabel: 'New skill', icon: 'i-lucide-sparkles', markdown: true },
11+ rule: { label: 'Rules', singular: 'Rule', newLabel: 'New rule', icon: 'i-lucide-scroll-text', markdown: true },
12+ agent: { label: 'Subagents', singular: 'Subagent', newLabel: 'New subagent', icon: 'i-lucide-bot', markdown: true },
13+ mcp: { label: 'MCP servers', singular: 'MCP server', newLabel: 'New MCP server', icon: 'i-lucide-plug', markdown: false },
14+ hook: { label: 'Hooks', singular: 'Hook', newLabel: 'New hook', icon: 'i-lucide-webhook', markdown: false }
1115 }
1216
1317 export const NAME_RE = /^[a-z0-9][a-z0-9._-]*$/
@@ -99,17 +103,94 @@ export function setFm(content: string, key: string, value: string): string {
99103 if (idx === -1) lines.push(line)
100104 else {
101105 let end = idx + 1
102- while (end < lines.length && /^\s+\S/.test(lines[end]!)) end++
106+ while (end < lines.length && /^(\s+\S|-\s)/.test(lines[end]!)) end++
103107 lines.splice(idx, end - idx, line)
104108 }
105109 return m[1] + lines.join('\n') + m[3] + content.slice(m[0].length)
106110 }
107111
112+/** Heading shown in a new skill's body while the name is still empty. */
113+export const PLACEHOLDER_TITLE = 'New skill'
114+
115+/** Template for a new Markdown item. A skill's heading carries the real name (or a placeholder until one is typed). */
108116 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'
117+ const head = `---\nname: ${fmValue(name)}\ndescription: ""\n---\n\n`
118+ if (type === 'skill') return head + `# ${name || PLACEHOLDER_TITLE}\n\nDescribe when and how the agent should use this skill.\n`
119+ if (type === 'rule') return head + '- \n'
120+ return head + 'You are a specialized subagent. Describe your role here.\n'
121+}
122+
123+/** Replaces the first `# from` heading line with `# to` (keeps a new skill's title in sync with its name). */
124+export function retitle(content: string, from: string, to: string): string {
125+ if (from === to) return content
126+ const lines = content.split('\n')
127+ const i = lines.findIndex(l => l.trimEnd() === `# ${from}`)
128+ if (i === -1) return content
129+ lines[i] = `# ${to}`
130+ return lines.join('\n')
131+}
132+
133+/* ------------------------------------------------ frontmatter lists (rule `paths`) */
134+
135+const FM_RE = /^(---\r?\n)([\s\S]*?)(\r?\n---[ \t]*(?:\r?\n|$))/
136+const unquote = (v: string) => v.trim().replace(/^(['"])(.*)\1$/, '$2')
137+
138+function splitFlow(inner: string): string[] {
139+ const out: string[] = []
140+ let cur = ''
141+ let quote = ''
142+ let depth = 0
143+ for (const ch of inner) {
144+ if (quote) {
145+ if (ch === quote) quote = ''
146+ else cur += ch
147+ } else if (ch === '"' || ch === '\'') quote = ch
148+ else if (ch === '{') { depth++; cur += ch }
149+ else if (ch === '}') { depth = Math.max(0, depth - 1); cur += ch }
150+ else if (ch === ',' && depth === 0) { out.push(cur); cur = '' }
151+ else cur += ch
152+ }
153+ out.push(cur)
154+ return out.map(x => x.trim()).filter(Boolean)
155+}
156+
157+/** Reads a frontmatter list: block (`- a`), flow (`[a, b]`) or a single scalar. */
158+export function getFmList(content: string, key: string): string[] {
159+ const m = FM_RE.exec(content)
160+ if (!m) return []
161+ const lines = m[2]!.split(/\r?\n/)
162+ const idx = lines.findIndex(l => l.startsWith(`${key}:`))
163+ if (idx === -1) return []
164+ const inline = lines[idx]!.slice(key.length + 1).trim()
165+ if (inline.startsWith('[') && inline.endsWith(']')) return splitFlow(inline.slice(1, -1)).map(unquote)
166+ if (inline) return [unquote(inline)]
167+ const out: string[] = []
168+ for (let k = idx + 1; k < lines.length; k++) {
169+ const item = /^\s*-\s+(.*)$/.exec(lines[k]!)
170+ if (!item) {
171+ if (/^\s+\S/.test(lines[k]!)) continue
172+ break
173+ }
174+ out.push(unquote(item[1]!))
175+ }
176+ return out.filter(Boolean)
177+}
178+
179+/** Sets (or removes, when empty) a top-level frontmatter list, written as a block list of quoted strings. */
180+export function setFmList(content: string, key: string, values: string[]): string {
181+ const vals = values.map(v => v.trim()).filter(Boolean)
182+ const block = vals.length ? [`${key}:`, ...vals.map(v => ` - ${JSON.stringify(v)}`)] : []
183+ const m = FM_RE.exec(content)
184+ if (!m) return block.length ? `---\n${block.join('\n')}\n---\n\n${content}` : content
185+ const lines = m[2]!.split(/\r?\n/)
186+ const idx = lines.findIndex(l => l.startsWith(`${key}:`))
187+ if (idx === -1) lines.push(...block)
188+ else {
189+ let end = idx + 1
190+ while (end < lines.length && /^(\s+\S|-\s)/.test(lines[end]!)) end++
191+ lines.splice(idx, end - idx, ...block)
192+ }
193+ return m[1] + lines.join('\n') + m[3] + content.slice(m[0].length)
113194 }
114195
115196 export const HOOK_EVENTS = [
@@ -1,13 +1,17 @@
1 import type { CatalogItemType, HookTemplate, ItemStatus, McpEnvValue, McpTemplate } from '@skilly/core'1 import type { CatalogItemType, HookTemplate, ItemStatus, McpEnvValue, McpTemplate } from '@skilly/core'
2+import { splitFrontmatter } from './markdown'
2 3
3 export const CAT_TYPES: CatalogItemType[] = ['skill', 'rule', 'agent', 'mcp', 'hook']4 export const CAT_TYPES: CatalogItemType[] = ['skill', 'rule', 'agent', 'mcp', 'hook']
4 5
5-export const CAT_META: Record<CatalogItemType, { label: string, singular: string, icon: string, markdown: boolean }> = {6+/** Display order in the UI (as in the design: skills, rules, MCP servers, hooks, subagents). */
6- skill: { label: 'Skills', singular: 'Skill', icon: 'i-lucide-sparkles', markdown: true },7+export const CAT_ORDER: CatalogItemType[] = ['skill', 'rule', 'mcp', 'hook', 'agent']
7- rule: { label: 'Rules', singular: 'Rule', icon: 'i-lucide-scroll-text', markdown: true },8+
8- agent: { label: 'Agents', singular: 'Agent', icon: 'i-lucide-bot', markdown: true },9+export const CAT_META: Record<CatalogItemType, { label: string, singular: string, newLabel: string, icon: string, markdown: boolean }> = {
9- mcp: { label: 'MCP', singular: 'MCP server', icon: 'i-lucide-plug', markdown: false },10+ skill: { label: 'Skills', singular: 'Skill', newLabel: 'New skill', icon: 'i-lucide-sparkles', markdown: true },
10- hook: { label: 'Hooks', singular: 'Hook', icon: 'i-lucide-webhook', markdown: false }11+ rule: { label: 'Rules', singular: 'Rule', newLabel: 'New rule', icon: 'i-lucide-scroll-text', markdown: true },
12+ agent: { label: 'Subagents', singular: 'Subagent', newLabel: 'New subagent', icon: 'i-lucide-bot', markdown: true },
13+ mcp: { label: 'MCP servers', singular: 'MCP server', newLabel: 'New MCP server', icon: 'i-lucide-plug', markdown: false },
14+ hook: { label: 'Hooks', singular: 'Hook', newLabel: 'New hook', icon: 'i-lucide-webhook', markdown: false }
11 }15 }
12 16
13 export const NAME_RE = /^[a-z0-9][a-z0-9._-]*$/17 export const NAME_RE = /^[a-z0-9][a-z0-9._-]*$/
@@ -99,17 +103,94 @@ export function setFm(content: string, key: string, value: string): string {
99 if (idx === -1) lines.push(line)103 if (idx === -1) lines.push(line)
100 else {104 else {
101 let end = idx + 1105 let end = idx + 1
102- while (end < lines.length && /^\s+\S/.test(lines[end]!)) end++106+ while (end < lines.length && /^(\s+\S|-\s)/.test(lines[end]!)) end++
103 lines.splice(idx, end - idx, line)107 lines.splice(idx, end - idx, line)
104 }108 }
105 return m[1] + lines.join('\n') + m[3] + content.slice(m[0].length)109 return m[1] + lines.join('\n') + m[3] + content.slice(m[0].length)
106 }110 }
107 111
112+/** Heading shown in a new skill's body while the name is still empty. */
113+export const PLACEHOLDER_TITLE = 'New skill'
114+
115+/** Template for a new Markdown item. A skill's heading carries the real name (or a placeholder until one is typed). */
108 export function newItemContent(type: CatalogItemType, name: string): string {116 export function newItemContent(type: CatalogItemType, name: string): string {
109- const head = `---\nname: ${name}\ndescription: \n---\n\n`117+ const head = `---\nname: ${fmValue(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`118+ if (type === 'skill') return head + `# ${name || PLACEHOLDER_TITLE}\n\nDescribe when and how the agent should use this skill.\n`
111- if (type === 'rule') return head.replace('description: \n', 'description: ""\n') + '- \n'119+ if (type === 'rule') return head + '- \n'
112- return head.replace('description: \n', 'description: ""\n') + 'You are a specialized subagent. Describe your role here.\n'120+ return head + 'You are a specialized subagent. Describe your role here.\n'
121+}
122+
123+/** Replaces the first `# from` heading line with `# to` (keeps a new skill's title in sync with its name). */
124+export function retitle(content: string, from: string, to: string): string {
125+ if (from === to) return content
126+ const lines = content.split('\n')
127+ const i = lines.findIndex(l => l.trimEnd() === `# ${from}`)
128+ if (i === -1) return content
129+ lines[i] = `# ${to}`
130+ return lines.join('\n')
131+}
132+
133+/* ------------------------------------------------ frontmatter lists (rule `paths`) */
134+
135+const FM_RE = /^(---\r?\n)([\s\S]*?)(\r?\n---[ \t]*(?:\r?\n|$))/
136+const unquote = (v: string) => v.trim().replace(/^(['"])(.*)\1$/, '$2')
137+
138+function splitFlow(inner: string): string[] {
139+ const out: string[] = []
140+ let cur = ''
141+ let quote = ''
142+ let depth = 0
143+ for (const ch of inner) {
144+ if (quote) {
145+ if (ch === quote) quote = ''
146+ else cur += ch
147+ } else if (ch === '"' || ch === '\'') quote = ch
148+ else if (ch === '{') { depth++; cur += ch }
149+ else if (ch === '}') { depth = Math.max(0, depth - 1); cur += ch }
150+ else if (ch === ',' && depth === 0) { out.push(cur); cur = '' }
151+ else cur += ch
152+ }
153+ out.push(cur)
154+ return out.map(x => x.trim()).filter(Boolean)
155+}
156+
157+/** Reads a frontmatter list: block (`- a`), flow (`[a, b]`) or a single scalar. */
158+export function getFmList(content: string, key: string): string[] {
159+ const m = FM_RE.exec(content)
160+ if (!m) return []
161+ const lines = m[2]!.split(/\r?\n/)
162+ const idx = lines.findIndex(l => l.startsWith(`${key}:`))
163+ if (idx === -1) return []
164+ const inline = lines[idx]!.slice(key.length + 1).trim()
165+ if (inline.startsWith('[') && inline.endsWith(']')) return splitFlow(inline.slice(1, -1)).map(unquote)
166+ if (inline) return [unquote(inline)]
167+ const out: string[] = []
168+ for (let k = idx + 1; k < lines.length; k++) {
169+ const item = /^\s*-\s+(.*)$/.exec(lines[k]!)
170+ if (!item) {
171+ if (/^\s+\S/.test(lines[k]!)) continue
172+ break
173+ }
174+ out.push(unquote(item[1]!))
175+ }
176+ return out.filter(Boolean)
177+}
178+
179+/** Sets (or removes, when empty) a top-level frontmatter list, written as a block list of quoted strings. */
180+export function setFmList(content: string, key: string, values: string[]): string {
181+ const vals = values.map(v => v.trim()).filter(Boolean)
182+ const block = vals.length ? [`${key}:`, ...vals.map(v => ` - ${JSON.stringify(v)}`)] : []
183+ const m = FM_RE.exec(content)
184+ if (!m) return block.length ? `---\n${block.join('\n')}\n---\n\n${content}` : content
185+ const lines = m[2]!.split(/\r?\n/)
186+ const idx = lines.findIndex(l => l.startsWith(`${key}:`))
187+ if (idx === -1) lines.push(...block)
188+ else {
189+ let end = idx + 1
190+ while (end < lines.length && /^(\s+\S|-\s)/.test(lines[end]!)) end++
191+ lines.splice(idx, end - idx, ...block)
192+ }
193+ return m[1] + lines.join('\n') + m[3] + content.slice(m[0].length)
113 }194 }
114 195
115 export const HOOK_EVENTS = [196 export const HOOK_EVENTS = [
added packages/ui/app/utils/gitConflicts.ts +40 -0
new file mode 100644
@@ -0,0 +1,40 @@
1+/** Catalog folder -> item type label, for naming conflicted paths in item terms (brief 4.10). */
2+const FOLDER_TYPES: Record<string, string> = {
3+ skills: 'Skill',
4+ rules: 'Rule',
5+ agents: 'Subagent',
6+ mcp: 'MCP',
7+ hooks: 'Hook',
8+ presets: 'Preset'
9+}
10+
11+export interface ConflictGroup {
12+ /** Stable key: `skills/nuxt`, or the path itself for files outside an item. */
13+ key: string
14+ /** Item name ("nuxt"), or the path for files outside an item. */
15+ name: string
16+ /** Type label ("Skill"), when the path belongs to an item. */
17+ type?: string
18+ paths: string[]
19+}
20+
21+/** Groups conflicted catalog paths by item: every file of `skills/<name>/` belongs to the skill `<name>`. */
22+export function groupConflicts(paths: string[]): ConflictGroup[] {
23+ const groups = new Map<string, ConflictGroup>()
24+ for (const p of paths) {
25+ const parts = p.replace(/\\/g, '/').split('/')
26+ const type = parts.length > 1 ? FOLDER_TYPES[parts[0]!] : undefined
27+ let key = p
28+ let name = p
29+ if (type && parts[1]) {
30+ // Folder items (skills) are a directory; the others are a single file named `<name>.<ext>`.
31+ const isFolder = parts[0] === 'skills' && parts.length > 2
32+ name = isFolder ? parts[1] : parts[1].replace(/\.[^.]+$/, '')
33+ key = `${parts[0]}/${name}`
34+ }
35+ const g = groups.get(key) ?? { key, name, type, paths: [] }
36+ g.paths.push(p)
37+ groups.set(key, g)
38+ }
39+ return [...groups.values()]
40+}
new file mode 100644
@@ -0,0 +1,40 @@
1+/** Catalog folder -> item type label, for naming conflicted paths in item terms (brief 4.10). */
2+const FOLDER_TYPES: Record<string, string> = {
3+ skills: 'Skill',
4+ rules: 'Rule',
5+ agents: 'Subagent',
6+ mcp: 'MCP',
7+ hooks: 'Hook',
8+ presets: 'Preset'
9+}
10+
11+export interface ConflictGroup {
12+ /** Stable key: `skills/nuxt`, or the path itself for files outside an item. */
13+ key: string
14+ /** Item name ("nuxt"), or the path for files outside an item. */
15+ name: string
16+ /** Type label ("Skill"), when the path belongs to an item. */
17+ type?: string
18+ paths: string[]
19+}
20+
21+/** Groups conflicted catalog paths by item: every file of `skills/<name>/` belongs to the skill `<name>`. */
22+export function groupConflicts(paths: string[]): ConflictGroup[] {
23+ const groups = new Map<string, ConflictGroup>()
24+ for (const p of paths) {
25+ const parts = p.replace(/\\/g, '/').split('/')
26+ const type = parts.length > 1 ? FOLDER_TYPES[parts[0]!] : undefined
27+ let key = p
28+ let name = p
29+ if (type && parts[1]) {
30+ // Folder items (skills) are a directory; the others are a single file named `<name>.<ext>`.
31+ const isFolder = parts[0] === 'skills' && parts.length > 2
32+ name = isFolder ? parts[1] : parts[1].replace(/\.[^.]+$/, '')
33+ key = `${parts[0]}/${name}`
34+ }
35+ const g = groups.get(key) ?? { key, name, type, paths: [] }
36+ g.paths.push(p)
37+ groups.set(key, g)
38+ }
39+ return [...groups.values()]
40+}
added packages/ui/app/utils/inventoryView.ts +114 -0
new file mode 100644
@@ -0,0 +1,114 @@
1+import type { AgentId, CatalogItemType, HookDetails, InstructionsDetails, InventoryItem, ItemStatus } from '@skilly/core'
2+import type { Tone } from './status'
3+
4+/**
5+ * Presentation helpers for inventory items (Home inventory, project Files tab, item drawer).
6+ * Pure functions: no Nuxt context needed, unit-tested in packages/ui/test.
7+ */
8+
9+/** Row title: hooks lead with their event, instruction files with their file name. */
10+export function itemDisplayName(i: InventoryItem): string {
11+ if (i.type === 'hook') {
12+ const d = i.details as HookDetails
13+ return d.matcher ? `${d.event} · ${d.matcher}` : d.event
14+ }
15+ if (i.type === 'instructions') return (i.details as InstructionsDetails).fileName
16+ return i.name
17+}
18+
19+const fwd = (p: string) => p.replace(/\\/g, '/')
20+
21+/** `p` relative to `root` (forward slashes), or the absolute path (forward slashes) when it lives elsewhere. */
22+export function pathInRoot(root: string, p: string): string {
23+ const r = fwd(root).replace(/\/+$/, '')
24+ const q = fwd(p)
25+ if (r && q.length > r.length && q.slice(0, r.length).toLowerCase() === r.toLowerCase() && q[r.length] === '/') return q.slice(r.length + 1)
26+ return q
27+}
28+
29+/** Mono line under a row title: path (with the config key for entries of a shared file). `prefix` is `~/` for Home. */
30+export function itemLocation(i: InventoryItem, root: string, prefix = ''): string {
31+ const rel = pathInRoot(root, i.source.path)
32+ const shown = rel === fwd(i.source.path) ? rel : `${prefix}${rel}`
33+ if (i.source.kind === 'config-entry') return i.source.configKey ? `${shown} · ${i.source.configKey}` : shown
34+ return i.type === 'skill' ? `${shown}/SKILL.md` : shown
35+}
36+
37+/** Directory a Home item lives in, for the "Loaded everywhere" breakdown: `~/.claude`, `~/.codex`, `~/.agents`. */
38+export function homeDirOf(i: InventoryItem, root: string): string {
39+ const rel = pathInRoot(root, i.source.path)
40+ if (rel === fwd(i.source.path)) return i.agent === 'codex' ? '~/.codex' : '~/.claude'
41+ const first = rel.split('/')[0] ?? rel
42+ return `~/${first === '.claude.json' ? '.claude' : first}`
43+}
44+
45+/** Status shown in Home rows ("In catalog" / "Local"). Managed Home items use the same words. */
46+export function homeStateMeta(state: ItemStatus['state'] | undefined): { tone: Tone, label: string } {
47+ switch (state) {
48+ case 'in-sync': return { tone: 'ok', label: 'In catalog' }
49+ case 'catalog-updated': return { tone: 'info', label: 'Update available' }
50+ case 'modified': return { tone: 'accent', label: 'Drift' }
51+ case 'differs-from-catalog': return { tone: 'accent', label: 'Differs from catalog' }
52+ default: return { tone: 'faint', label: 'Local' }
53+ }
54+}
55+
56+/** Types that "Remove from global" supports. MCP servers and hooks of Home are read-only in v1. */
57+export const MOVABLE_TYPES: ReadonlySet<string> = new Set(['skill', 'rule', 'agent'])
58+
59+/** True when the item can be moved out of Home (skills, rules, subagents not provided by a plugin nor managed by skilly). */
60+export function canMoveOutOfHome(i: InventoryItem, status?: ItemStatus): boolean {
61+ return MOVABLE_TYPES.has(i.type) && !i.source.plugin && !status?.managed
62+}
63+
64+/** True when the item can be copied to the catalog (not instructions, not yet identical to the catalog version). */
65+export function canAddToCatalog(i: InventoryItem, status?: ItemStatus): boolean {
66+ return i.type !== 'instructions' && !i.source.plugin && (status?.state === 'not-in-catalog' || status?.state === 'differs-from-catalog')
67+}
68+
69+export interface ProjectSpecific {
70+ /** Names of the projects using the item. */
71+ projects: string[]
72+ /** Hint under the suggestion tile. */
73+ hint: string
74+}
75+
76+/**
77+ * Brief 4.5: a Home item looks project-specific when its catalog counterpart is enabled in at least one project
78+ * but not in all of them. Computed client side from `CatalogResponse.usage`; never moves anything by itself.
79+ */
80+export function projectSpecific(
81+ item: InventoryItem,
82+ status: ItemStatus | undefined,
83+ usage: Record<string, string[]>,
84+ projects: { id: string, name: string, exists?: boolean }[]
85+): ProjectSpecific | null {
86+ if (!MOVABLE_TYPES.has(item.type) || item.source.plugin) return null
87+ const ref = status?.catalogRef ?? { type: item.type as CatalogItemType, name: item.name }
88+ const users = usage[`${ref.type}/${ref.name}`] ?? []
89+ const live = projects.filter(p => p.exists !== false)
90+ const using = live.filter(p => users.includes(p.id))
91+ if (!using.length || using.length >= live.length) return null
92+ const names = using.map(p => p.name)
93+ return { projects: names, hint: names.length === 1 ? `used in ${names[0]}` : `used in ${names.length} projects` }
94+}
95+
96+/** Agents that show up in a list of items, in the canonical order. */
97+export function agentsOf(items: { agent: AgentId }[]): AgentId[] {
98+ const set = new Set(items.map(i => i.agent))
99+ return (['claude', 'codex'] as AgentId[]).filter(a => set.has(a))
100+}
101+
102+/** Splits an instruction file into plain and skilly-managed segments (the AGENTS.md block between skilly:begin and skilly:end). */
103+export function splitManagedBlock(text: string): { text: string, managed: boolean }[] {
104+ const lines = text.split('\n')
105+ const begin = lines.findIndex(l => l.startsWith('<!-- skilly:begin'))
106+ if (begin === -1) return [{ text, managed: false }]
107+ let end = lines.findIndex((l, idx) => idx > begin && l.startsWith('<!-- skilly:end'))
108+ if (end === -1) end = lines.length - 1
109+ const out: { text: string, managed: boolean }[] = []
110+ if (begin > 0) out.push({ text: lines.slice(0, begin).join('\n') + '\n', managed: false })
111+ out.push({ text: lines.slice(begin, end + 1).join('\n') + (end < lines.length - 1 ? '\n' : ''), managed: true })
112+ if (end < lines.length - 1) out.push({ text: lines.slice(end + 1).join('\n'), managed: false })
113+ return out
114+}
new file mode 100644
@@ -0,0 +1,114 @@
1+import type { AgentId, CatalogItemType, HookDetails, InstructionsDetails, InventoryItem, ItemStatus } from '@skilly/core'
2+import type { Tone } from './status'
3+
4+/**
5+ * Presentation helpers for inventory items (Home inventory, project Files tab, item drawer).
6+ * Pure functions: no Nuxt context needed, unit-tested in packages/ui/test.
7+ */
8+
9+/** Row title: hooks lead with their event, instruction files with their file name. */
10+export function itemDisplayName(i: InventoryItem): string {
11+ if (i.type === 'hook') {
12+ const d = i.details as HookDetails
13+ return d.matcher ? `${d.event} · ${d.matcher}` : d.event
14+ }
15+ if (i.type === 'instructions') return (i.details as InstructionsDetails).fileName
16+ return i.name
17+}
18+
19+const fwd = (p: string) => p.replace(/\\/g, '/')
20+
21+/** `p` relative to `root` (forward slashes), or the absolute path (forward slashes) when it lives elsewhere. */
22+export function pathInRoot(root: string, p: string): string {
23+ const r = fwd(root).replace(/\/+$/, '')
24+ const q = fwd(p)
25+ if (r && q.length > r.length && q.slice(0, r.length).toLowerCase() === r.toLowerCase() && q[r.length] === '/') return q.slice(r.length + 1)
26+ return q
27+}
28+
29+/** Mono line under a row title: path (with the config key for entries of a shared file). `prefix` is `~/` for Home. */
30+export function itemLocation(i: InventoryItem, root: string, prefix = ''): string {
31+ const rel = pathInRoot(root, i.source.path)
32+ const shown = rel === fwd(i.source.path) ? rel : `${prefix}${rel}`
33+ if (i.source.kind === 'config-entry') return i.source.configKey ? `${shown} · ${i.source.configKey}` : shown
34+ return i.type === 'skill' ? `${shown}/SKILL.md` : shown
35+}
36+
37+/** Directory a Home item lives in, for the "Loaded everywhere" breakdown: `~/.claude`, `~/.codex`, `~/.agents`. */
38+export function homeDirOf(i: InventoryItem, root: string): string {
39+ const rel = pathInRoot(root, i.source.path)
40+ if (rel === fwd(i.source.path)) return i.agent === 'codex' ? '~/.codex' : '~/.claude'
41+ const first = rel.split('/')[0] ?? rel
42+ return `~/${first === '.claude.json' ? '.claude' : first}`
43+}
44+
45+/** Status shown in Home rows ("In catalog" / "Local"). Managed Home items use the same words. */
46+export function homeStateMeta(state: ItemStatus['state'] | undefined): { tone: Tone, label: string } {
47+ switch (state) {
48+ case 'in-sync': return { tone: 'ok', label: 'In catalog' }
49+ case 'catalog-updated': return { tone: 'info', label: 'Update available' }
50+ case 'modified': return { tone: 'accent', label: 'Drift' }
51+ case 'differs-from-catalog': return { tone: 'accent', label: 'Differs from catalog' }
52+ default: return { tone: 'faint', label: 'Local' }
53+ }
54+}
55+
56+/** Types that "Remove from global" supports. MCP servers and hooks of Home are read-only in v1. */
57+export const MOVABLE_TYPES: ReadonlySet<string> = new Set(['skill', 'rule', 'agent'])
58+
59+/** True when the item can be moved out of Home (skills, rules, subagents not provided by a plugin nor managed by skilly). */
60+export function canMoveOutOfHome(i: InventoryItem, status?: ItemStatus): boolean {
61+ return MOVABLE_TYPES.has(i.type) && !i.source.plugin && !status?.managed
62+}
63+
64+/** True when the item can be copied to the catalog (not instructions, not yet identical to the catalog version). */
65+export function canAddToCatalog(i: InventoryItem, status?: ItemStatus): boolean {
66+ return i.type !== 'instructions' && !i.source.plugin && (status?.state === 'not-in-catalog' || status?.state === 'differs-from-catalog')
67+}
68+
69+export interface ProjectSpecific {
70+ /** Names of the projects using the item. */
71+ projects: string[]
72+ /** Hint under the suggestion tile. */
73+ hint: string
74+}
75+
76+/**
77+ * Brief 4.5: a Home item looks project-specific when its catalog counterpart is enabled in at least one project
78+ * but not in all of them. Computed client side from `CatalogResponse.usage`; never moves anything by itself.
79+ */
80+export function projectSpecific(
81+ item: InventoryItem,
82+ status: ItemStatus | undefined,
83+ usage: Record<string, string[]>,
84+ projects: { id: string, name: string, exists?: boolean }[]
85+): ProjectSpecific | null {
86+ if (!MOVABLE_TYPES.has(item.type) || item.source.plugin) return null
87+ const ref = status?.catalogRef ?? { type: item.type as CatalogItemType, name: item.name }
88+ const users = usage[`${ref.type}/${ref.name}`] ?? []
89+ const live = projects.filter(p => p.exists !== false)
90+ const using = live.filter(p => users.includes(p.id))
91+ if (!using.length || using.length >= live.length) return null
92+ const names = using.map(p => p.name)
93+ return { projects: names, hint: names.length === 1 ? `used in ${names[0]}` : `used in ${names.length} projects` }
94+}
95+
96+/** Agents that show up in a list of items, in the canonical order. */
97+export function agentsOf(items: { agent: AgentId }[]): AgentId[] {
98+ const set = new Set(items.map(i => i.agent))
99+ return (['claude', 'codex'] as AgentId[]).filter(a => set.has(a))
100+}
101+
102+/** Splits an instruction file into plain and skilly-managed segments (the AGENTS.md block between skilly:begin and skilly:end). */
103+export function splitManagedBlock(text: string): { text: string, managed: boolean }[] {
104+ const lines = text.split('\n')
105+ const begin = lines.findIndex(l => l.startsWith('<!-- skilly:begin'))
106+ if (begin === -1) return [{ text, managed: false }]
107+ let end = lines.findIndex((l, idx) => idx > begin && l.startsWith('<!-- skilly:end'))
108+ if (end === -1) end = lines.length - 1
109+ const out: { text: string, managed: boolean }[] = []
110+ if (begin > 0) out.push({ text: lines.slice(0, begin).join('\n') + '\n', managed: false })
111+ out.push({ text: lines.slice(begin, end + 1).join('\n') + (end < lines.length - 1 ? '\n' : ''), managed: true })
112+ if (end < lines.length - 1) out.push({ text: lines.slice(end + 1).join('\n'), managed: false })
113+ return out
114+}
added packages/ui/app/utils/planView.ts +57 -0
new file mode 100644
@@ -0,0 +1,57 @@
1+import type { PlanAction, ScopeState } from '@skilly/core'
2+
3+/** Plan helpers shared by the Projects table, the pending bar and the review drawer. */
4+
5+export const isPending = (a: PlanAction) => a.kind !== 'unchanged'
6+
7+export interface PlanSummary {
8+ tone: 'ok' | 'info' | 'accent'
9+ label: string
10+}
11+
12+const plural = (n: number, w: string) => `${n} ${w}${n === 1 ? '' : 's'}`
13+
14+/** "1 drift · 1 update" / "2 updates" / "Up to date": distinct items, not per-agent actions. */
15+export function summarizePlan(plan: ScopeState['plan']): PlanSummary {
16+ const pending = (plan?.actions ?? []).filter(isPending)
17+ const distinct = (pred: (a: PlanAction) => boolean) => new Set(pending.filter(pred).map(a => `${a.type}/${a.name}`)).size
18+ const drift = distinct(a => a.kind === 'conflict' && a.conflict === 'modified-locally')
19+ const conflicts = distinct(a => a.kind === 'conflict' && a.conflict !== 'modified-locally')
20+ const updates = distinct(a => a.kind === 'update')
21+ const others = distinct(a => a.kind === 'add' || a.kind === 'remove')
22+ const parts: string[] = []
23+ if (drift) parts.push(`${drift} drift`)
24+ if (conflicts) parts.push(plural(conflicts, 'conflict'))
25+ if (updates) parts.push(plural(updates, 'update'))
26+ if (others) parts.push(plural(others, 'change'))
27+ if (!parts.length) return { tone: 'ok', label: 'Up to date' }
28+ return { tone: drift || conflicts ? 'accent' : 'info', label: parts.join(' · ') }
29+}
30+
31+/** Number of distinct files a set of actions touches (toast copy: "Applied 4 changes · 2 files written"). */
32+export function filesTouched(actions: PlanAction[]): number {
33+ const files = new Set<string>()
34+ for (const a of actions) {
35+ a.files.forEach(f => files.add(f))
36+ a.configKeys.forEach(c => files.add(c.file))
37+ }
38+ return files.size
39+}
40+
41+/** The item's own file when it has one (SKILL.md, rule or subagent markdown), null for shared configuration entries. */
42+export function comparableFile(a: PlanAction): string | null {
43+ const norm = (f: string) => f.replace(/\\/g, '/')
44+ if (a.type === 'skill') return a.files.map(norm).find(f => f.endsWith(`/${a.name}/SKILL.md`)) ?? null
45+ if (a.type === 'rule' || a.type === 'agent') return a.files.map(norm).find(f => f.endsWith(`/${a.name}.md`)) ?? null
46+ return null
47+}
48+
49+/** Where a scope writes, for the hint "Written to .claude/ and .agents/". */
50+export function writtenToHint(targets: string[], home: boolean): string {
51+ const dirs: string[] = []
52+ if (targets.includes('claude')) dirs.push(home ? '~/.claude/' : '.claude/')
53+ if (targets.includes('codex')) dirs.push(...(home ? ['~/.agents/', '~/.codex/'] : ['.agents/', '.codex/']))
54+ if (!dirs.length) return 'No target agent: nothing is written'
55+ const list = dirs.length > 1 ? `${dirs.slice(0, -1).join(', ')} and ${dirs[dirs.length - 1]}` : dirs[0]
56+ return home ? `Written to ${list}` : `Written to ${list} · committed with the project`
57+}
new file mode 100644
@@ -0,0 +1,57 @@
1+import type { PlanAction, ScopeState } from '@skilly/core'
2+
3+/** Plan helpers shared by the Projects table, the pending bar and the review drawer. */
4+
5+export const isPending = (a: PlanAction) => a.kind !== 'unchanged'
6+
7+export interface PlanSummary {
8+ tone: 'ok' | 'info' | 'accent'
9+ label: string
10+}
11+
12+const plural = (n: number, w: string) => `${n} ${w}${n === 1 ? '' : 's'}`
13+
14+/** "1 drift · 1 update" / "2 updates" / "Up to date": distinct items, not per-agent actions. */
15+export function summarizePlan(plan: ScopeState['plan']): PlanSummary {
16+ const pending = (plan?.actions ?? []).filter(isPending)
17+ const distinct = (pred: (a: PlanAction) => boolean) => new Set(pending.filter(pred).map(a => `${a.type}/${a.name}`)).size
18+ const drift = distinct(a => a.kind === 'conflict' && a.conflict === 'modified-locally')
19+ const conflicts = distinct(a => a.kind === 'conflict' && a.conflict !== 'modified-locally')
20+ const updates = distinct(a => a.kind === 'update')
21+ const others = distinct(a => a.kind === 'add' || a.kind === 'remove')
22+ const parts: string[] = []
23+ if (drift) parts.push(`${drift} drift`)
24+ if (conflicts) parts.push(plural(conflicts, 'conflict'))
25+ if (updates) parts.push(plural(updates, 'update'))
26+ if (others) parts.push(plural(others, 'change'))
27+ if (!parts.length) return { tone: 'ok', label: 'Up to date' }
28+ return { tone: drift || conflicts ? 'accent' : 'info', label: parts.join(' · ') }
29+}
30+
31+/** Number of distinct files a set of actions touches (toast copy: "Applied 4 changes · 2 files written"). */
32+export function filesTouched(actions: PlanAction[]): number {
33+ const files = new Set<string>()
34+ for (const a of actions) {
35+ a.files.forEach(f => files.add(f))
36+ a.configKeys.forEach(c => files.add(c.file))
37+ }
38+ return files.size
39+}
40+
41+/** The item's own file when it has one (SKILL.md, rule or subagent markdown), null for shared configuration entries. */
42+export function comparableFile(a: PlanAction): string | null {
43+ const norm = (f: string) => f.replace(/\\/g, '/')
44+ if (a.type === 'skill') return a.files.map(norm).find(f => f.endsWith(`/${a.name}/SKILL.md`)) ?? null
45+ if (a.type === 'rule' || a.type === 'agent') return a.files.map(norm).find(f => f.endsWith(`/${a.name}.md`)) ?? null
46+ return null
47+}
48+
49+/** Where a scope writes, for the hint "Written to .claude/ and .agents/". */
50+export function writtenToHint(targets: string[], home: boolean): string {
51+ const dirs: string[] = []
52+ if (targets.includes('claude')) dirs.push(home ? '~/.claude/' : '.claude/')
53+ if (targets.includes('codex')) dirs.push(...(home ? ['~/.agents/', '~/.codex/'] : ['.agents/', '.codex/']))
54+ if (!dirs.length) return 'No target agent: nothing is written'
55+ const list = dirs.length > 1 ? `${dirs.slice(0, -1).join(', ')} and ${dirs[dirs.length - 1]}` : dirs[0]
56+ return home ? `Written to ${list}` : `Written to ${list} · committed with the project`
57+}
added packages/ui/app/utils/relativeTime.ts +15 -0
new file mode 100644
@@ -0,0 +1,15 @@
1+/** "just now", "5 min ago", "2 h ago", "3 days ago", then a plain date. Empty string for an invalid date. */
2+export function timeAgo(iso: string | undefined, now = Date.now()): string {
3+ if (!iso) return ''
4+ const t = new Date(iso).getTime()
5+ if (Number.isNaN(t)) return ''
6+ const s = Math.max(0, Math.round((now - t) / 1000))
7+ if (s < 45) return 'just now'
8+ const m = Math.round(s / 60)
9+ if (m < 60) return `${m} min ago`
10+ const h = Math.round(m / 60)
11+ if (h < 24) return `${h} h ago`
12+ const d = Math.round(h / 24)
13+ if (d < 14) return `${d} day${d === 1 ? '' : 's'} ago`
14+ return new Date(t).toLocaleDateString()
15+}
new file mode 100644
@@ -0,0 +1,15 @@
1+/** "just now", "5 min ago", "2 h ago", "3 days ago", then a plain date. Empty string for an invalid date. */
2+export function timeAgo(iso: string | undefined, now = Date.now()): string {
3+ if (!iso) return ''
4+ const t = new Date(iso).getTime()
5+ if (Number.isNaN(t)) return ''
6+ const s = Math.max(0, Math.round((now - t) / 1000))
7+ if (s < 45) return 'just now'
8+ const m = Math.round(s / 60)
9+ if (m < 60) return `${m} min ago`
10+ const h = Math.round(m / 60)
11+ if (h < 24) return `${h} h ago`
12+ const d = Math.round(h / 24)
13+ if (d < 14) return `${d} day${d === 1 ? '' : 's'} ago`
14+ return new Date(t).toLocaleDateString()
15+}
added packages/ui/app/utils/scopeRows.ts +229 -0
new file mode 100644
@@ -0,0 +1,229 @@
1+import type {
2+ AgentId, CatalogItem, CatalogItemType, ConflictKind, ItemStatus, PlanAction, Preset, ScopeConfig, ScopeInventory, SyncPlan
3+} from '@skilly/core'
4+import { ITEM_STATE_META, ITEM_STATE_SHORT, type Tone } from './status'
5+import { resolveItems } from './resolve'
6+
7+/**
8+ * View model of the "Items in this project" list (project Configuration tab, Home Configuration tab).
9+ * One row per logical item (`type/name`), with one status per target agent. Built from the local config copy,
10+ * the sync plan and the inventory. Pure and unit-tested.
11+ */
12+
13+export type RowAction = 'resolve' | 'review' | 'import'
14+
15+export interface AgentRowStatus {
16+ agent: AgentId
17+ tone: Tone
18+ label: string
19+ /** Short form for per-agent mini statuses. */
20+ short: string
21+ action?: RowAction
22+ /** Conflict action of the plan, when there is one. */
23+ conflict?: PlanAction
24+}
25+
26+export type RowKind = 'entry' | 'removal' | 'local'
27+
28+export interface ScopeRow {
29+ key: string
30+ kind: RowKind
31+ type: CatalogItemType
32+ name: string
33+ /** Presets that provide the item. */
34+ presets: string[]
35+ /** Added one by one. */
36+ added: boolean
37+ /** Matched by an exclusion (exact name or glob). */
38+ excluded: boolean
39+ /** Exclusion is an exact name (can be toggled) rather than a glob pattern. */
40+ excludedExact: boolean
41+ excludePattern?: string
42+ inCatalog: boolean
43+ origin: string
44+ statuses: AgentRowStatus[]
45+ conflicts: PlanAction[]
46+ /** True when the switch toggles a preset exclusion; false when it removes an `add`. */
47+ viaPreset: boolean
48+ onDemand: { on: boolean, exact: boolean, pattern?: string }
49+}
50+
51+export const ROW_TYPE_ORDER: CatalogItemType[] = ['skill', 'rule', 'mcp', 'hook', 'agent']
52+
53+export function globMatches(pattern: string, name: string): boolean {
54+ if (!pattern.includes('*')) return pattern === name
55+ const esc = (s: string) => s.replace(/[.+?^${}()|[\]\\]/g, c => '\\' + c)
56+ return new RegExp('^' + pattern.split('*').map(esc).join('.*') + '$').test(name)
57+}
58+
59+/** How `name` is matched by a list: exactly, or by a glob pattern. */
60+export function matchIn(list: string[] | undefined, name: string): { exact: boolean, pattern?: string } | null {
61+ const l = list ?? []
62+ if (l.includes(name)) return { exact: true }
63+ const g = l.find(p => p.includes('*') && globMatches(p, name))
64+ return g ? { exact: false, pattern: g } : null
65+}
66+
67+/** Label of a staged conflict resolution, shown next to the row. */
68+export const RESOLUTION_LABEL = {
69+ 'keep-local': 'keep local',
70+ 'take-catalog': 'take catalog',
71+ 'push-to-catalog': 'push to catalog'
72+} as const
73+
74+const CONFLICT_STATUS: Record<ConflictKind, { label: string, short: string }> = {
75+ 'modified-locally': { label: 'Drift', short: 'Drift' },
76+ 'exists-unmanaged': { label: 'Exists, not managed', short: 'Exists' },
77+ 'missing-in-catalog': { label: 'Not in catalog', short: 'Missing' }
78+}
79+
80+export interface HomeNames { skill: Set<string>, mcp: Set<string> }
81+
82+/** Plan actions that write the "hide global" entries are not items of the project: they have no row. */
83+export function isHideAction(a: PlanAction, config: ScopeConfig, entryKeys: Set<string>, homeNames?: HomeNames): boolean {
84+ if (a.type !== 'skill' && a.type !== 'mcp') return false
85+ if (entryKeys.has(`${a.type}/${a.name}`)) return false
86+ if (!homeNames || !homeNames[a.type].has(a.name)) return false
87+ const patterns = a.type === 'skill' ? config.hideGlobal.skills : config.hideGlobal.mcp
88+ return a.kind === 'remove' || !!matchIn(patterns, a.name)
89+}
90+
91+function statusFromAction(a: PlanAction | undefined, inv: ItemStatus | undefined, excluded: boolean, agent: AgentId): AgentRowStatus {
92+ if (a) {
93+ switch (a.kind) {
94+ case 'conflict': {
95+ const c = CONFLICT_STATUS[a.conflict ?? 'modified-locally']
96+ return { agent, tone: 'accent', label: c.label, short: c.short, action: 'resolve', conflict: a }
97+ }
98+ case 'add': return { agent, tone: 'info', label: 'Will add', short: 'Add', action: 'review' }
99+ case 'update':
100+ return a.catalogUpdated
101+ ? { agent, tone: 'info', label: 'Update available', short: 'Update', action: 'review' }
102+ : { agent, tone: 'info', label: 'Will update', short: 'Update', action: 'review' }
103+ case 'remove': return { agent, tone: 'accent', label: 'Will remove', short: 'Remove', action: 'review' }
104+ case 'unchanged': return { agent, tone: 'ok', label: 'Up to date', short: 'Up to date' }
105+ }
106+ }
107+ if (excluded) return { agent, tone: 'faint', label: 'Not installed', short: 'Off' }
108+ if (inv) {
109+ const meta = ITEM_STATE_META[inv.state]
110+ return { agent, tone: meta.tone, label: meta.label, short: ITEM_STATE_SHORT[inv.state] }
111+ }
112+ return { agent, tone: 'faint', label: 'Not written yet', short: 'Pending' }
113+}
114+
115+export interface BuildRowsInput {
116+ config: ScopeConfig
117+ presets: Preset[]
118+ catalogItems: CatalogItem[]
119+ plan: SyncPlan | null
120+ inventory: ScopeInventory | null
121+ statuses: Record<string, ItemStatus>
122+ /** Names of the Home skills / MCP servers, to tell "hide global" actions from item actions. */
123+ homeNames?: HomeNames
124+ /** Also list unmanaged local items of the scope (projects). */
125+ includeLocal: boolean
126+}
127+
128+export function buildRows(input: BuildRowsInput): ScopeRow[] {
129+ const { config, presets, catalogItems, plan, inventory, statuses } = input
130+ const resolved = resolveItems(presets, config.presets, config.add)
131+ const inCatalog = (t: CatalogItemType, n: string) => catalogItems.some(i => i.type === t && i.name === n)
132+ const entryKeys = new Set(resolved.entries.map(e => `${e.type}/${e.name}`))
133+ const actionsOf = (t: string, n: string) => (plan?.actions ?? []).filter(a => a.type === t && a.name === n)
134+ const invOf = (t: string, n: string, agent: AgentId): ItemStatus | undefined => {
135+ for (const it of inventory?.items ?? []) {
136+ const st = statuses[it.id]
137+ if (it.agent === agent && st?.catalogRef?.type === t && st.catalogRef.name === n) return st
138+ }
139+ return undefined
140+ }
141+
142+ const rows: ScopeRow[] = []
143+
144+ for (const e of resolved.entries) {
145+ const ex = matchIn(config.exclude[e.type], e.name)
146+ const presetsVia = e.via.filter(v => v !== 'add')
147+ const added = e.via.includes('add')
148+ const acts = actionsOf(e.type, e.name)
149+ const statusesPerAgent = config.targets.map(agent =>
150+ statusFromAction(acts.find(a => a.agent === agent), invOf(e.type, e.name, agent), !!ex, agent))
151+ let origin: string
152+ if (ex) origin = presetsVia.length ? `Excluded from ${presetsVia.join(', ')}` : ex.exact ? 'Excluded' : `Excluded by ${ex.pattern}`
153+ else if (presetsVia.length) origin = `Preset · ${presetsVia.join(', ')}${added ? ' + added' : ''}`
154+ else origin = 'Catalog'
155+ const od = matchIn(config.onDemand.skills, e.name)
156+ rows.push({
157+ key: `${e.type}/${e.name}`,
158+ kind: 'entry',
159+ type: e.type,
160+ name: e.name,
161+ presets: presetsVia,
162+ added,
163+ excluded: !!ex,
164+ excludedExact: !!ex?.exact,
165+ excludePattern: ex && !ex.exact ? ex.pattern : undefined,
166+ inCatalog: inCatalog(e.type, e.name),
167+ origin,
168+ statuses: statusesPerAgent,
169+ conflicts: statusesPerAgent.flatMap(s => (s.conflict ? [s.conflict] : [])),
170+ viaPreset: presetsVia.length > 0,
171+ onDemand: { on: !!od, exact: od?.exact ?? true, pattern: od && !od.exact ? od.pattern : undefined }
172+ })
173+ }
174+
175+ // Lock entries that are no longer enabled: removal or conflict actions without a configured item.
176+ const seen = new Set<string>()
177+ for (const a of plan?.actions ?? []) {
178+ const key = `${a.type}/${a.name}`
179+ if (entryKeys.has(key) || seen.has(key)) continue
180+ if (a.kind !== 'remove' && a.kind !== 'conflict') continue
181+ if (isHideAction(a, config, entryKeys, input.homeNames)) continue
182+ seen.add(key)
183+ const st = actionsOf(a.type, a.name).map(x => statusFromAction(x, undefined, false, x.agent))
184+ rows.push({
185+ key, kind: 'removal', type: a.type, name: a.name, presets: [], added: false, excluded: true, excludedExact: false,
186+ inCatalog: inCatalog(a.type, a.name), origin: 'No longer enabled', statuses: st,
187+ conflicts: st.flatMap(s => (s.conflict ? [s.conflict] : [])), viaPreset: false, onDemand: { on: false, exact: true }
188+ })
189+ }
190+
191+ if (input.includeLocal) {
192+ const groups = new Map<string, ScopeRow>()
193+ for (const it of inventory?.items ?? []) {
194+ const st = statuses[it.id]
195+ if (it.type === 'instructions' || it.type === 'hook' || it.source.plugin || st?.managed) continue
196+ const t = it.type as CatalogItemType
197+ const key = `${t}/${it.name}`
198+ if (entryKeys.has(key) || seen.has(key)) continue
199+ const state = st?.state ?? 'not-in-catalog'
200+ const meta = ITEM_STATE_META[state]
201+ const status: AgentRowStatus = state === 'in-sync'
202+ ? { agent: it.agent, tone: 'ok', label: 'In catalog', short: 'In catalog' }
203+ : {
204+ agent: it.agent, tone: meta.tone, label: meta.label, short: ITEM_STATE_SHORT[state],
205+ action: state === 'not-in-catalog' || state === 'differs-from-catalog' ? 'import' : undefined
206+ }
207+ const row = groups.get(key) ?? {
208+ key, kind: 'local' as const, type: t, name: it.name, presets: [], added: false, excluded: false, excludedExact: false,
209+ inCatalog: inCatalog(t, it.name), origin: 'Local', statuses: [], conflicts: [], viaPreset: false, onDemand: { on: false, exact: true }
210+ }
211+ row.statuses.push(status)
212+ groups.set(key, row)
213+ }
214+ rows.push(...groups.values())
215+ }
216+
217+ const kindOrder: Record<RowKind, number> = { entry: 0, removal: 1, local: 2 }
218+ return rows.sort((a, b) =>
219+ ROW_TYPE_ORDER.indexOf(a.type) - ROW_TYPE_ORDER.indexOf(b.type)
220+ || kindOrder[a.kind] - kindOrder[b.kind]
221+ || a.name.localeCompare(b.name))
222+}
223+
224+/** One merged status when every agent agrees, otherwise the caller shows one mini status per agent. */
225+export function mergeStatuses(statuses: AgentRowStatus[]): AgentRowStatus | null {
226+ if (!statuses.length) return null
227+ const first = statuses[0]!
228+ return statuses.every(s => s.label === first.label && s.tone === first.tone) ? first : null
229+}
new file mode 100644
@@ -0,0 +1,229 @@
1+import type {
2+ AgentId, CatalogItem, CatalogItemType, ConflictKind, ItemStatus, PlanAction, Preset, ScopeConfig, ScopeInventory, SyncPlan
3+} from '@skilly/core'
4+import { ITEM_STATE_META, ITEM_STATE_SHORT, type Tone } from './status'
5+import { resolveItems } from './resolve'
6+
7+/**
8+ * View model of the "Items in this project" list (project Configuration tab, Home Configuration tab).
9+ * One row per logical item (`type/name`), with one status per target agent. Built from the local config copy,
10+ * the sync plan and the inventory. Pure and unit-tested.
11+ */
12+
13+export type RowAction = 'resolve' | 'review' | 'import'
14+
15+export interface AgentRowStatus {
16+ agent: AgentId
17+ tone: Tone
18+ label: string
19+ /** Short form for per-agent mini statuses. */
20+ short: string
21+ action?: RowAction
22+ /** Conflict action of the plan, when there is one. */
23+ conflict?: PlanAction
24+}
25+
26+export type RowKind = 'entry' | 'removal' | 'local'
27+
28+export interface ScopeRow {
29+ key: string
30+ kind: RowKind
31+ type: CatalogItemType
32+ name: string
33+ /** Presets that provide the item. */
34+ presets: string[]
35+ /** Added one by one. */
36+ added: boolean
37+ /** Matched by an exclusion (exact name or glob). */
38+ excluded: boolean
39+ /** Exclusion is an exact name (can be toggled) rather than a glob pattern. */
40+ excludedExact: boolean
41+ excludePattern?: string
42+ inCatalog: boolean
43+ origin: string
44+ statuses: AgentRowStatus[]
45+ conflicts: PlanAction[]
46+ /** True when the switch toggles a preset exclusion; false when it removes an `add`. */
47+ viaPreset: boolean
48+ onDemand: { on: boolean, exact: boolean, pattern?: string }
49+}
50+
51+export const ROW_TYPE_ORDER: CatalogItemType[] = ['skill', 'rule', 'mcp', 'hook', 'agent']
52+
53+export function globMatches(pattern: string, name: string): boolean {
54+ if (!pattern.includes('*')) return pattern === name
55+ const esc = (s: string) => s.replace(/[.+?^${}()|[\]\\]/g, c => '\\' + c)
56+ return new RegExp('^' + pattern.split('*').map(esc).join('.*') + '$').test(name)
57+}
58+
59+/** How `name` is matched by a list: exactly, or by a glob pattern. */
60+export function matchIn(list: string[] | undefined, name: string): { exact: boolean, pattern?: string } | null {
61+ const l = list ?? []
62+ if (l.includes(name)) return { exact: true }
63+ const g = l.find(p => p.includes('*') && globMatches(p, name))
64+ return g ? { exact: false, pattern: g } : null
65+}
66+
67+/** Label of a staged conflict resolution, shown next to the row. */
68+export const RESOLUTION_LABEL = {
69+ 'keep-local': 'keep local',
70+ 'take-catalog': 'take catalog',
71+ 'push-to-catalog': 'push to catalog'
72+} as const
73+
74+const CONFLICT_STATUS: Record<ConflictKind, { label: string, short: string }> = {
75+ 'modified-locally': { label: 'Drift', short: 'Drift' },
76+ 'exists-unmanaged': { label: 'Exists, not managed', short: 'Exists' },
77+ 'missing-in-catalog': { label: 'Not in catalog', short: 'Missing' }
78+}
79+
80+export interface HomeNames { skill: Set<string>, mcp: Set<string> }
81+
82+/** Plan actions that write the "hide global" entries are not items of the project: they have no row. */
83+export function isHideAction(a: PlanAction, config: ScopeConfig, entryKeys: Set<string>, homeNames?: HomeNames): boolean {
84+ if (a.type !== 'skill' && a.type !== 'mcp') return false
85+ if (entryKeys.has(`${a.type}/${a.name}`)) return false
86+ if (!homeNames || !homeNames[a.type].has(a.name)) return false
87+ const patterns = a.type === 'skill' ? config.hideGlobal.skills : config.hideGlobal.mcp
88+ return a.kind === 'remove' || !!matchIn(patterns, a.name)
89+}
90+
91+function statusFromAction(a: PlanAction | undefined, inv: ItemStatus | undefined, excluded: boolean, agent: AgentId): AgentRowStatus {
92+ if (a) {
93+ switch (a.kind) {
94+ case 'conflict': {
95+ const c = CONFLICT_STATUS[a.conflict ?? 'modified-locally']
96+ return { agent, tone: 'accent', label: c.label, short: c.short, action: 'resolve', conflict: a }
97+ }
98+ case 'add': return { agent, tone: 'info', label: 'Will add', short: 'Add', action: 'review' }
99+ case 'update':
100+ return a.catalogUpdated
101+ ? { agent, tone: 'info', label: 'Update available', short: 'Update', action: 'review' }
102+ : { agent, tone: 'info', label: 'Will update', short: 'Update', action: 'review' }
103+ case 'remove': return { agent, tone: 'accent', label: 'Will remove', short: 'Remove', action: 'review' }
104+ case 'unchanged': return { agent, tone: 'ok', label: 'Up to date', short: 'Up to date' }
105+ }
106+ }
107+ if (excluded) return { agent, tone: 'faint', label: 'Not installed', short: 'Off' }
108+ if (inv) {
109+ const meta = ITEM_STATE_META[inv.state]
110+ return { agent, tone: meta.tone, label: meta.label, short: ITEM_STATE_SHORT[inv.state] }
111+ }
112+ return { agent, tone: 'faint', label: 'Not written yet', short: 'Pending' }
113+}
114+
115+export interface BuildRowsInput {
116+ config: ScopeConfig
117+ presets: Preset[]
118+ catalogItems: CatalogItem[]
119+ plan: SyncPlan | null
120+ inventory: ScopeInventory | null
121+ statuses: Record<string, ItemStatus>
122+ /** Names of the Home skills / MCP servers, to tell "hide global" actions from item actions. */
123+ homeNames?: HomeNames
124+ /** Also list unmanaged local items of the scope (projects). */
125+ includeLocal: boolean
126+}
127+
128+export function buildRows(input: BuildRowsInput): ScopeRow[] {
129+ const { config, presets, catalogItems, plan, inventory, statuses } = input
130+ const resolved = resolveItems(presets, config.presets, config.add)
131+ const inCatalog = (t: CatalogItemType, n: string) => catalogItems.some(i => i.type === t && i.name === n)
132+ const entryKeys = new Set(resolved.entries.map(e => `${e.type}/${e.name}`))
133+ const actionsOf = (t: string, n: string) => (plan?.actions ?? []).filter(a => a.type === t && a.name === n)
134+ const invOf = (t: string, n: string, agent: AgentId): ItemStatus | undefined => {
135+ for (const it of inventory?.items ?? []) {
136+ const st = statuses[it.id]
137+ if (it.agent === agent && st?.catalogRef?.type === t && st.catalogRef.name === n) return st
138+ }
139+ return undefined
140+ }
141+
142+ const rows: ScopeRow[] = []
143+
144+ for (const e of resolved.entries) {
145+ const ex = matchIn(config.exclude[e.type], e.name)
146+ const presetsVia = e.via.filter(v => v !== 'add')
147+ const added = e.via.includes('add')
148+ const acts = actionsOf(e.type, e.name)
149+ const statusesPerAgent = config.targets.map(agent =>
150+ statusFromAction(acts.find(a => a.agent === agent), invOf(e.type, e.name, agent), !!ex, agent))
151+ let origin: string
152+ if (ex) origin = presetsVia.length ? `Excluded from ${presetsVia.join(', ')}` : ex.exact ? 'Excluded' : `Excluded by ${ex.pattern}`
153+ else if (presetsVia.length) origin = `Preset · ${presetsVia.join(', ')}${added ? ' + added' : ''}`
154+ else origin = 'Catalog'
155+ const od = matchIn(config.onDemand.skills, e.name)
156+ rows.push({
157+ key: `${e.type}/${e.name}`,
158+ kind: 'entry',
159+ type: e.type,
160+ name: e.name,
161+ presets: presetsVia,
162+ added,
163+ excluded: !!ex,
164+ excludedExact: !!ex?.exact,
165+ excludePattern: ex && !ex.exact ? ex.pattern : undefined,
166+ inCatalog: inCatalog(e.type, e.name),
167+ origin,
168+ statuses: statusesPerAgent,
169+ conflicts: statusesPerAgent.flatMap(s => (s.conflict ? [s.conflict] : [])),
170+ viaPreset: presetsVia.length > 0,
171+ onDemand: { on: !!od, exact: od?.exact ?? true, pattern: od && !od.exact ? od.pattern : undefined }
172+ })
173+ }
174+
175+ // Lock entries that are no longer enabled: removal or conflict actions without a configured item.
176+ const seen = new Set<string>()
177+ for (const a of plan?.actions ?? []) {
178+ const key = `${a.type}/${a.name}`
179+ if (entryKeys.has(key) || seen.has(key)) continue
180+ if (a.kind !== 'remove' && a.kind !== 'conflict') continue
181+ if (isHideAction(a, config, entryKeys, input.homeNames)) continue
182+ seen.add(key)
183+ const st = actionsOf(a.type, a.name).map(x => statusFromAction(x, undefined, false, x.agent))
184+ rows.push({
185+ key, kind: 'removal', type: a.type, name: a.name, presets: [], added: false, excluded: true, excludedExact: false,
186+ inCatalog: inCatalog(a.type, a.name), origin: 'No longer enabled', statuses: st,
187+ conflicts: st.flatMap(s => (s.conflict ? [s.conflict] : [])), viaPreset: false, onDemand: { on: false, exact: true }
188+ })
189+ }
190+
191+ if (input.includeLocal) {
192+ const groups = new Map<string, ScopeRow>()
193+ for (const it of inventory?.items ?? []) {
194+ const st = statuses[it.id]
195+ if (it.type === 'instructions' || it.type === 'hook' || it.source.plugin || st?.managed) continue
196+ const t = it.type as CatalogItemType
197+ const key = `${t}/${it.name}`
198+ if (entryKeys.has(key) || seen.has(key)) continue
199+ const state = st?.state ?? 'not-in-catalog'
200+ const meta = ITEM_STATE_META[state]
201+ const status: AgentRowStatus = state === 'in-sync'
202+ ? { agent: it.agent, tone: 'ok', label: 'In catalog', short: 'In catalog' }
203+ : {
204+ agent: it.agent, tone: meta.tone, label: meta.label, short: ITEM_STATE_SHORT[state],
205+ action: state === 'not-in-catalog' || state === 'differs-from-catalog' ? 'import' : undefined
206+ }
207+ const row = groups.get(key) ?? {
208+ key, kind: 'local' as const, type: t, name: it.name, presets: [], added: false, excluded: false, excludedExact: false,
209+ inCatalog: inCatalog(t, it.name), origin: 'Local', statuses: [], conflicts: [], viaPreset: false, onDemand: { on: false, exact: true }
210+ }
211+ row.statuses.push(status)
212+ groups.set(key, row)
213+ }
214+ rows.push(...groups.values())
215+ }
216+
217+ const kindOrder: Record<RowKind, number> = { entry: 0, removal: 1, local: 2 }
218+ return rows.sort((a, b) =>
219+ ROW_TYPE_ORDER.indexOf(a.type) - ROW_TYPE_ORDER.indexOf(b.type)
220+ || kindOrder[a.kind] - kindOrder[b.kind]
221+ || a.name.localeCompare(b.name))
222+}
223+
224+/** One merged status when every agent agrees, otherwise the caller shows one mini status per agent. */
225+export function mergeStatuses(statuses: AgentRowStatus[]): AgentRowStatus | null {
226+ if (!statuses.length) return null
227+ const first = statuses[0]!
228+ return statuses.every(s => s.label === first.label && s.tone === first.tone) ? first : null
229+}
added packages/ui/test/catalog-frontmatter.test.ts +62 -0
new file mode 100644
@@ -0,0 +1,62 @@
1+import { describe, expect, it } from 'vitest'
2+import { parseFrontmatter } from '../../core/src/util.ts'
3+import { getFm, getFmList, newItemContent, retitle, setFm, setFmList } from '../app/utils/catalog.ts'
4+
5+describe('newItemContent / retitle', () => {
6+ it('uses the real name in the skill heading and a placeholder before a name exists', () => {
7+ expect(newItemContent('skill', 'seo-audit')).toContain('# seo-audit\n')
8+ expect(newItemContent('skill', '')).toContain('# New skill\n')
9+ expect(newItemContent('skill', '')).not.toContain('new-item')
10+ })
11+
12+ it('retitles the heading as the name is typed', () => {
13+ let c = newItemContent('skill', '')
14+ c = retitle(c, 'New skill', 'my')
15+ c = retitle(c, 'my', 'my-skill')
16+ expect(c).toContain('# my-skill\n')
17+ expect(c).not.toContain('# my\n')
18+ // an edited heading is left alone
19+ const edited = c.replace('# my-skill', '# Custom title')
20+ expect(retitle(edited, 'my-skill', 'other')).toBe(edited)
21+ })
22+})
23+
24+describe('frontmatter lists (rule paths)', () => {
25+ const base = '---\nname: ts\ndescription: "x"\n---\n\nbody\n'
26+
27+ it('writes a block list the core YAML parser reads back', () => {
28+ const globs = ['src/**/*.ts', 'app/**/*.{ts,tsx}', '*.md']
29+ const out = setFmList(base, 'paths', globs)
30+ expect(parseFrontmatter(out).frontmatter.paths).toEqual(globs)
31+ expect(getFmList(out, 'paths')).toEqual(globs)
32+ expect(out.endsWith('\nbody\n')).toBe(true)
33+ })
34+
35+ it('replaces and removes an existing list without touching other keys', () => {
36+ let out = setFmList(base, 'paths', ['a/**', 'b/**'])
37+ out = setFmList(out, 'paths', ['c/**'])
38+ expect(getFmList(out, 'paths')).toEqual(['c/**'])
39+ expect(getFm(out, 'name')).toBe('ts')
40+ out = setFmList(out, 'paths', [])
41+ expect(out).toBe(base)
42+ })
43+
44+ it('reads flow lists and single scalars', () => {
45+ expect(getFmList('---\npaths: ["a/**", \'b/*.{x,y}\']\n---\n', 'paths')).toEqual(['a/**', 'b/*.{x,y}'])
46+ expect(getFmList('---\npaths: src/**/*.ts\n---\n', 'paths')).toEqual(['src/**/*.ts'])
47+ expect(getFmList('---\nname: a\n---\n', 'paths')).toEqual([])
48+ })
49+
50+ it('keeps a list intact when another key is set', () => {
51+ const withPaths = setFmList(base, 'paths', ['a/**'])
52+ const out = setFm(withPaths, 'description', 'new')
53+ expect(getFmList(out, 'paths')).toEqual(['a/**'])
54+ expect(getFm(out, 'description')).toBe('new')
55+ })
56+
57+ it('replaces an unindented block list when the key is rewritten', () => {
58+ const c = '---\nname: a\npaths:\n- x/**\n- y/**\n---\nbody\n'
59+ expect(getFmList(c, 'paths')).toEqual(['x/**', 'y/**'])
60+ expect(getFmList(setFmList(c, 'paths', ['z']), 'paths')).toEqual(['z'])
61+ })
62+})
new file mode 100644
@@ -0,0 +1,62 @@
1+import { describe, expect, it } from 'vitest'
2+import { parseFrontmatter } from '../../core/src/util.ts'
3+import { getFm, getFmList, newItemContent, retitle, setFm, setFmList } from '../app/utils/catalog.ts'
4+
5+describe('newItemContent / retitle', () => {
6+ it('uses the real name in the skill heading and a placeholder before a name exists', () => {
7+ expect(newItemContent('skill', 'seo-audit')).toContain('# seo-audit\n')
8+ expect(newItemContent('skill', '')).toContain('# New skill\n')
9+ expect(newItemContent('skill', '')).not.toContain('new-item')
10+ })
11+
12+ it('retitles the heading as the name is typed', () => {
13+ let c = newItemContent('skill', '')
14+ c = retitle(c, 'New skill', 'my')
15+ c = retitle(c, 'my', 'my-skill')
16+ expect(c).toContain('# my-skill\n')
17+ expect(c).not.toContain('# my\n')
18+ // an edited heading is left alone
19+ const edited = c.replace('# my-skill', '# Custom title')
20+ expect(retitle(edited, 'my-skill', 'other')).toBe(edited)
21+ })
22+})
23+
24+describe('frontmatter lists (rule paths)', () => {
25+ const base = '---\nname: ts\ndescription: "x"\n---\n\nbody\n'
26+
27+ it('writes a block list the core YAML parser reads back', () => {
28+ const globs = ['src/**/*.ts', 'app/**/*.{ts,tsx}', '*.md']
29+ const out = setFmList(base, 'paths', globs)
30+ expect(parseFrontmatter(out).frontmatter.paths).toEqual(globs)
31+ expect(getFmList(out, 'paths')).toEqual(globs)
32+ expect(out.endsWith('\nbody\n')).toBe(true)
33+ })
34+
35+ it('replaces and removes an existing list without touching other keys', () => {
36+ let out = setFmList(base, 'paths', ['a/**', 'b/**'])
37+ out = setFmList(out, 'paths', ['c/**'])
38+ expect(getFmList(out, 'paths')).toEqual(['c/**'])
39+ expect(getFm(out, 'name')).toBe('ts')
40+ out = setFmList(out, 'paths', [])
41+ expect(out).toBe(base)
42+ })
43+
44+ it('reads flow lists and single scalars', () => {
45+ expect(getFmList('---\npaths: ["a/**", \'b/*.{x,y}\']\n---\n', 'paths')).toEqual(['a/**', 'b/*.{x,y}'])
46+ expect(getFmList('---\npaths: src/**/*.ts\n---\n', 'paths')).toEqual(['src/**/*.ts'])
47+ expect(getFmList('---\nname: a\n---\n', 'paths')).toEqual([])
48+ })
49+
50+ it('keeps a list intact when another key is set', () => {
51+ const withPaths = setFmList(base, 'paths', ['a/**'])
52+ const out = setFm(withPaths, 'description', 'new')
53+ expect(getFmList(out, 'paths')).toEqual(['a/**'])
54+ expect(getFm(out, 'description')).toBe('new')
55+ })
56+
57+ it('replaces an unindented block list when the key is rewritten', () => {
58+ const c = '---\nname: a\npaths:\n- x/**\n- y/**\n---\nbody\n'
59+ expect(getFmList(c, 'paths')).toEqual(['x/**', 'y/**'])
60+ expect(getFmList(setFmList(c, 'paths', ['z']), 'paths')).toEqual(['z'])
61+ })
62+})
added packages/ui/test/scope-views.test.ts +76 -0
new file mode 100644
@@ -0,0 +1,76 @@
1+import { describe, expect, it } from 'vitest'
2+import type { InventoryItem, ScopeConfig, SyncPlan } from '@skilly/core'
3+import { buildRows, matchIn, mergeStatuses } from '../app/utils/scopeRows'
4+import { projectSpecific, splitManagedBlock, homeDirOf } from '../app/utils/inventoryView'
5+import { summarizePlan, writtenToHint } from '../app/utils/planView'
6+
7+const config = (over: Partial<ScopeConfig> = {}): ScopeConfig => ({
8+ version: 1, targets: ['claude'], presets: ['base'], add: { skill: ['extra'] }, exclude: {}, onDemand: { skills: ['seo-*'] },
9+ hideGlobal: { skills: [], mcp: [] }, ...over
10+})
11+const presets = [{ name: 'base', includes: [], items: { skill: ['pr-review', 'seo-audit'] } }]
12+const plan = (actions: SyncPlan['actions']): SyncPlan => ({ scope: 'project', root: '/p', actions, warnings: [], missing: [] })
13+const act = (kind: any, name: string, extra: object = {}) =>
14+ ({ id: `${kind}:claude:skill/${name}`, kind, type: 'skill', name, agent: 'claude', files: [], configKeys: [], reason: '', ...extra }) as SyncPlan['actions'][number]
15+
16+describe('buildRows', () => {
17+ it('lists preset and added items with origin, exclusion and on-demand glob', () => {
18+ const rows = buildRows({
19+ config: config({ exclude: { skill: ['pr-review'] } }), presets, catalogItems: [], includeLocal: false,
20+ plan: plan([act('unchanged', 'seo-audit'), act('conflict', 'extra', { conflict: 'modified-locally' })]), inventory: null, statuses: {}
21+ })
22+ const by = Object.fromEntries(rows.map(r => [r.name, r]))
23+ expect(Object.keys(by).sort()).toEqual(['extra', 'pr-review', 'seo-audit'])
24+ expect(by['pr-review']!.excluded).toBe(true)
25+ expect(by['pr-review']!.origin).toBe('Excluded from base')
26+ expect(by['seo-audit']!.onDemand).toEqual({ on: true, exact: false, pattern: 'seo-*' })
27+ expect(by.extra!.origin).toBe('Catalog')
28+ expect(by.extra!.statuses[0]!.action).toBe('resolve')
29+ expect(by.extra!.conflicts).toHaveLength(1)
30+ })
31+
32+ it('adds a removal row for lock entries that are no longer enabled', () => {
33+ const rows = buildRows({ config: config({ presets: [], add: {} }), presets, catalogItems: [], includeLocal: false, plan: plan([act('remove', 'old')]), inventory: null, statuses: {} })
34+ expect(rows).toHaveLength(1)
35+ expect(rows[0]!.kind).toBe('removal')
36+ })
37+
38+ it('merges equal statuses and keeps different ones apart', () => {
39+ const a = { agent: 'claude', tone: 'ok', label: 'Up to date', short: '' } as const
40+ expect(mergeStatuses([a, { ...a, agent: 'codex' }])).not.toBeNull()
41+ expect(mergeStatuses([a, { ...a, agent: 'codex', tone: 'accent', label: 'Drift' }])).toBeNull()
42+ })
43+
44+ it('matches names exactly or by glob', () => {
45+ expect(matchIn(['a', 'x-*'], 'x-1')).toEqual({ exact: false, pattern: 'x-*' })
46+ expect(matchIn(['a'], 'b')).toBeNull()
47+ })
48+})
49+
50+describe('inventory view helpers', () => {
51+ const item = { id: 'i', type: 'skill', name: 'seo', agent: 'claude', source: { path: '/h/.claude/skills/seo', kind: 'dir' }, details: {}, warnings: [] } as unknown as InventoryItem
52+ it('flags items used by some but not all projects', () => {
53+ const projects = [{ id: 'a', name: 'A' }, { id: 'b', name: 'B' }]
54+ expect(projectSpecific(item, undefined, { 'skill/seo': ['a'] }, projects)?.hint).toBe('used in A')
55+ expect(projectSpecific(item, undefined, { 'skill/seo': ['a', 'b'] }, projects)).toBeNull()
56+ expect(projectSpecific(item, undefined, {}, projects)).toBeNull()
57+ })
58+ it('groups Home items by directory', () => {
59+ expect(homeDirOf(item, '/h')).toBe('~/.claude')
60+ })
61+ it('splits the managed AGENTS.md block', () => {
62+ const segs = splitManagedBlock('a\n<!-- skilly:begin (x) -->\nr\n<!-- skilly:end -->\nb')
63+ expect(segs.map(s => s.managed)).toEqual([false, true, false])
64+ })
65+})
66+
67+describe('plan view helpers', () => {
68+ it('summarizes drift and updates by distinct item', () => {
69+ const s = summarizePlan(plan([act('conflict', 'a', { conflict: 'modified-locally' }), act('update', 'b'), act('update', 'b', { agent: 'codex' })]))
70+ expect(s).toEqual({ tone: 'accent', label: '1 drift · 1 update' })
71+ expect(summarizePlan(null).label).toBe('Up to date')
72+ })
73+ it('describes where a scope writes', () => {
74+ expect(writtenToHint(['claude', 'codex'], false)).toBe('Written to .claude/, .agents/ and .codex/ · committed with the project')
75+ })
76+})
new file mode 100644
@@ -0,0 +1,76 @@
1+import { describe, expect, it } from 'vitest'
2+import type { InventoryItem, ScopeConfig, SyncPlan } from '@skilly/core'
3+import { buildRows, matchIn, mergeStatuses } from '../app/utils/scopeRows'
4+import { projectSpecific, splitManagedBlock, homeDirOf } from '../app/utils/inventoryView'
5+import { summarizePlan, writtenToHint } from '../app/utils/planView'
6+
7+const config = (over: Partial<ScopeConfig> = {}): ScopeConfig => ({
8+ version: 1, targets: ['claude'], presets: ['base'], add: { skill: ['extra'] }, exclude: {}, onDemand: { skills: ['seo-*'] },
9+ hideGlobal: { skills: [], mcp: [] }, ...over
10+})
11+const presets = [{ name: 'base', includes: [], items: { skill: ['pr-review', 'seo-audit'] } }]
12+const plan = (actions: SyncPlan['actions']): SyncPlan => ({ scope: 'project', root: '/p', actions, warnings: [], missing: [] })
13+const act = (kind: any, name: string, extra: object = {}) =>
14+ ({ id: `${kind}:claude:skill/${name}`, kind, type: 'skill', name, agent: 'claude', files: [], configKeys: [], reason: '', ...extra }) as SyncPlan['actions'][number]
15+
16+describe('buildRows', () => {
17+ it('lists preset and added items with origin, exclusion and on-demand glob', () => {
18+ const rows = buildRows({
19+ config: config({ exclude: { skill: ['pr-review'] } }), presets, catalogItems: [], includeLocal: false,
20+ plan: plan([act('unchanged', 'seo-audit'), act('conflict', 'extra', { conflict: 'modified-locally' })]), inventory: null, statuses: {}
21+ })
22+ const by = Object.fromEntries(rows.map(r => [r.name, r]))
23+ expect(Object.keys(by).sort()).toEqual(['extra', 'pr-review', 'seo-audit'])
24+ expect(by['pr-review']!.excluded).toBe(true)
25+ expect(by['pr-review']!.origin).toBe('Excluded from base')
26+ expect(by['seo-audit']!.onDemand).toEqual({ on: true, exact: false, pattern: 'seo-*' })
27+ expect(by.extra!.origin).toBe('Catalog')
28+ expect(by.extra!.statuses[0]!.action).toBe('resolve')
29+ expect(by.extra!.conflicts).toHaveLength(1)
30+ })
31+
32+ it('adds a removal row for lock entries that are no longer enabled', () => {
33+ const rows = buildRows({ config: config({ presets: [], add: {} }), presets, catalogItems: [], includeLocal: false, plan: plan([act('remove', 'old')]), inventory: null, statuses: {} })
34+ expect(rows).toHaveLength(1)
35+ expect(rows[0]!.kind).toBe('removal')
36+ })
37+
38+ it('merges equal statuses and keeps different ones apart', () => {
39+ const a = { agent: 'claude', tone: 'ok', label: 'Up to date', short: '' } as const
40+ expect(mergeStatuses([a, { ...a, agent: 'codex' }])).not.toBeNull()
41+ expect(mergeStatuses([a, { ...a, agent: 'codex', tone: 'accent', label: 'Drift' }])).toBeNull()
42+ })
43+
44+ it('matches names exactly or by glob', () => {
45+ expect(matchIn(['a', 'x-*'], 'x-1')).toEqual({ exact: false, pattern: 'x-*' })
46+ expect(matchIn(['a'], 'b')).toBeNull()
47+ })
48+})
49+
50+describe('inventory view helpers', () => {
51+ const item = { id: 'i', type: 'skill', name: 'seo', agent: 'claude', source: { path: '/h/.claude/skills/seo', kind: 'dir' }, details: {}, warnings: [] } as unknown as InventoryItem
52+ it('flags items used by some but not all projects', () => {
53+ const projects = [{ id: 'a', name: 'A' }, { id: 'b', name: 'B' }]
54+ expect(projectSpecific(item, undefined, { 'skill/seo': ['a'] }, projects)?.hint).toBe('used in A')
55+ expect(projectSpecific(item, undefined, { 'skill/seo': ['a', 'b'] }, projects)).toBeNull()
56+ expect(projectSpecific(item, undefined, {}, projects)).toBeNull()
57+ })
58+ it('groups Home items by directory', () => {
59+ expect(homeDirOf(item, '/h')).toBe('~/.claude')
60+ })
61+ it('splits the managed AGENTS.md block', () => {
62+ const segs = splitManagedBlock('a\n<!-- skilly:begin (x) -->\nr\n<!-- skilly:end -->\nb')
63+ expect(segs.map(s => s.managed)).toEqual([false, true, false])
64+ })
65+})
66+
67+describe('plan view helpers', () => {
68+ it('summarizes drift and updates by distinct item', () => {
69+ const s = summarizePlan(plan([act('conflict', 'a', { conflict: 'modified-locally' }), act('update', 'b'), act('update', 'b', { agent: 'codex' })]))
70+ expect(s).toEqual({ tone: 'accent', label: '1 drift · 1 update' })
71+ expect(summarizePlan(null).label).toBe('Up to date')
72+ })
73+ it('describes where a scope writes', () => {
74+ expect(writtenToHint(['claude', 'codex'], false)).toBe('Written to .claude/, .agents/ and .codex/ · committed with the project')
75+ })
76+})