diff --git a/docker/setup/app.js b/docker/setup/app.js index a4650cb..228afac 100644 --- a/docker/setup/app.js +++ b/docker/setup/app.js @@ -3454,7 +3454,9 @@ 'storage.preferences': () => storageUI.preferences(), 'storage.versions': () => storageUI.reviewVersions(), 'storage.details': () => storageUI.toggle(), + 'skills.check-updates': () => skillsUI.checkUpdates(), 'skills.refresh': () => skillsUI.refresh(), + 'skills.bundled': () => skillsUI.toggleBundled(), 'skills.view': (a) => skillsUI.view(a.id), 'skills.add': () => skillsUI.add(), 'skills.import': () => skillsUI.install(), diff --git a/docker/setup/client/skill-import.js b/docker/setup/client/skill-import.js index a2f246f..61e7313 100644 --- a/docker/setup/client/skill-import.js +++ b/docker/setup/client/skill-import.js @@ -12,12 +12,13 @@ const T3SkillImport = (() => { const open = (options = {}) => { if (active) return; const library = getLibrary(); - const view = { source: options.source || '', preview: null, skills: new Set(), agents: new Set(options.agents || library.installed), mode: 'fewest', + const view = { source: options.source || '', preview: null, skills: new Set(), agents: new Set(options.agents || library.installed), mode: options.mode === 'each' ? 'each' : 'fewest', railOpen: !Kit.isPhone(), step: 1, query: '', catalogQuery: '', expanded: new Set(), plan: null, result: null, replace: false, error: null, reading: false, planning: false, installing: false, retryInstall: false, expired: false, generation: 0, planTimer: null, expiryTimer: null, planRequest: null, sourceRequest: null, closed: false, layer: null, metadata: '' }; active = view; const selectedSkills = () => (view.preview?.skills || []).filter(skill => view.skills.has(skill.name)); - const planner = () => S.placement([...view.agents], getLibrary().installed, getLibrary().roots, view.mode); + const agentIds = () => [...new Set([...(getLibrary().agents || getLibrary().installed), ...view.agents])]; + const planner = () => S.placement([...view.agents], [...new Set([...getLibrary().installed, ...view.agents])], getLibrary().roots, view.mode); const placements = () => view.plan?.destinations || []; const replacements = () => placements().filter(entry => entry.action === 'replace'); const copies = () => placements().length; @@ -132,15 +133,15 @@ const T3SkillImport = (() => { const agentRow = (provider, destination) => { const id = provider.id, selected = view.agents.has(id), duplicate = planner().duplicates.includes(id); const own = id === destination || planner().destinations.find(folder => folder.provider === destination)?.also.includes(id); - return html``; + return html``; }; const agentPicker = () => { const placement = planner(), groups = view.plan ? destinationGroups(placements()) : placement.destinations.map(destination => ({ key: destination.root?.path || destination.provider, provider: destination.provider, entries: [] })); return html`

Which agents get these skills?

Each copy goes where its agents already look.

${groups.map(group => html`
${icon('folder')}${S.displayPath(group.key.startsWith('blocked:') ? S.nameOf(group.provider) : group.key, library.home)}·${S.nameOf(group.provider)} folder${group.entries.some(entry => entry.action === 'blocked') ? 'Blocked' : group.entries.length ? M.plural(group.entries.filter(entry => entry.action === 'add').length, 'new', 'new') + (group.entries.some(entry => entry.action === 'replace') ? ' · ' + group.entries.filter(entry => entry.action === 'replace').length + ' replaces yours' : '') : view.planning ? 'Checking…' : ''}
${S.providers.filter(provider => view.agents.has(provider.id) && placement.assignments[provider.id] === group.provider).map(provider => agentRow(provider, group.provider))}${group.entries.filter(entry => entry.action === 'blocked').map(entry => html``)}`)} - ${S.providers.filter(provider => getLibrary().installed.includes(provider.id) && (!view.agents.has(provider.id) || placement.uncovered.includes(provider.id))).map(provider => agentRow(provider, null))} + ${S.providers.filter(provider => agentIds().includes(provider.id) && (!view.agents.has(provider.id) || placement.uncovered.includes(provider.id))).map(provider => agentRow(provider, null))} ${placement.uncovered.length ? html`` : ''} - ${!getLibrary().installed.length ? html`

Install an agent from Agents in Setup, then reopen this installer to choose its folder.

` : !view.agents.size ? html`

Choose at least one agent above.

` : ''} + ${!agentIds().length ? html`

Show other agents in the library to manage their folders before installing them.

` : !view.agents.size ? html`

Choose at least one agent above.

` : ''}
${replacements().length ? html`` : ''} ${view.error && !view.expired && !view.retryInstall ? html`` : ''}`; @@ -151,7 +152,7 @@ const T3SkillImport = (() => { const planRail = () => { const placement = planner(); const notInstalled = S.providers.filter(provider => !getLibrary().installed.includes(provider.id)); - const agentSummary = html`
${view.agents.size}of ${getLibrary().installed.length} agents
${S.providers.filter(provider => view.agents.has(provider.id)).map(provider => mark(provider.id))}
${notInstalled.length ? html`

${M.listOf(notInstalled.map(provider => provider.name))} ${notInstalled.length === 1 ? 'isn’t' : 'aren’t'} installed.

` : ''}
`; + const agentSummary = html`
${view.agents.size}of ${agentIds().length} agents
${S.providers.filter(provider => view.agents.has(provider.id)).map(provider => mark(provider.id))}
${notInstalled.length ? html`

${M.listOf(notInstalled.map(provider => provider.name))} ${notInstalled.length === 1 ? 'isn’t' : 'aren’t'} installed.

` : ''}
`; let summary; if (view.step === 1) { summary = html`
${view.skills.size}skills
Agents

Next, choose agents. Every installed agent starts selected.

`; diff --git a/docker/setup/client/skill-inspector.js b/docker/setup/client/skill-inspector.js index f4c3dbc..bdce509 100644 --- a/docker/setup/client/skill-inspector.js +++ b/docker/setup/client/skill-inspector.js @@ -45,6 +45,11 @@ const T3SkillInspector = (() => { ${stateVolume || homeVolume ? html`
Kept on
${icon('hard-drive', 'tc-icon--sm')}${stateVolume ? 'State volume' : 'Home volume'}, survives a recreate
` : ''} `; }; + const renderOrigin = () => { + const current = row(); + if (!current?.origin) return ''; + return html`
Installed from
${current.origin.source}

${current.update?.status === 'available' ? 'An update is available. Review the source before replacing this folder, including local edits.' : current.update?.status === 'current' ? 'Installed source snapshot is up to date. Local edits stay in place until you replace this folder.' : current.update?.status === 'error' ? 'Could not check for updates: ' + current.update.error : 'Use Check for updates in the library to check this repository.'}

`; + }; const renderFiles = () => html`
Files${view.data?.files ? T3Model.plural(view.data.files.length, 'file') : ''}
${view.loading ? html`

Reading file details…

` : view.data?.files ? html`
${icon('info', 'tc-icon--sm')}Scripts are never run here.
` : html`

${view.data?.filesError || (view.data?.removalFiles ? T3Model.plural(view.data.removalFiles, 'file') : 'File details are unavailable.')}

`}
`; @@ -77,7 +82,7 @@ const T3SkillInspector = (() => { render: () => html`

${creating ? 'New skill' : skill.name}

${creating ? 'Write instructions for your agents' : html`${skill.groupLabel}·${detail()?.canEdit ? 'Editable' : 'Read-only'}${view.data?.files ? html`·${T3Model.plural(view.data.files.length, 'file')}` : ''}${view.data ? html`·${bytes(view.data.bytes)}` : ''}`}
${view.error ? html`` : ''} - ${creating ? html`

Other agents may read this folder too. Shared file changes affect everyone using it.

Lowercase letters, numbers and hyphens. The frontmatter name must match.

` : html`

${skill.description}

${renderAvailability()}${renderLocations()}${renderFiles()}`} + ${creating ? html`

Other agents may read this folder too. Shared file changes affect everyone using it.

Lowercase letters, numbers and hyphens. The frontmatter name must match.

` : html`

${skill.description}

${renderAvailability()}${renderOrigin()}${renderLocations()}${renderFiles()}`} ${renderEditor()}
${footer()}
` }); window.addEventListener('beforeunload', beforeUnload); @@ -124,6 +129,11 @@ const T3SkillInspector = (() => { if ((action === 'data-inspect-copy-path')) { Kit.copy(S.directory(detail().path), button, 'Skill folder path copied'); return; } if ((action === 'data-inspect-upload')) { view.layer.panel.querySelector('#skill-file').click(); return; } if ((action === 'data-inspect-edit')) { view.editing = true; repaint(); view.layer.panel.querySelector('#skill-content')?.focus(); return; } + if (action === 'data-inspect-review-source') { + const current = row(); + if (await view.close('review-source')) getLibrary().install({ source: current.origin.source, names: [current.origin.name], agents: [current.provider], step: 2, mode: 'each' }); + return; + } if ((action === 'data-inspect-copy-to') || (action === 'data-inspect-reinstall')) { const agent = action === 'data-inspect-copy-to' ? button.getAttribute('data-inspect-copy-to') : null; if (!await view.close('copy')) return; diff --git a/docker/setup/client/skills.js b/docker/setup/client/skills.js index 205018a..b16f80b 100644 --- a/docker/setup/client/skills.js +++ b/docker/setup/client/skills.js @@ -8,9 +8,12 @@ const T3SkillsUI = (() => { const create = ({ api, getStatus, render, readMarkdown, downloadMarkdown }) => { const inventories = {}, errors = {}, epochs = {}, loading = new Set(), changing = new Map(), collapsed = new Map(); let installed = [], reach = null, rows = [], started = false, scan = null, query = '', agent = '', needsAttention = false, showAll = false, markup = null, highlighted = new Set(); + const filterKey = 't3-console-hide-bundled-skills'; + let hideBundled = false, checking = false; + try { hideBundled = localStorage.getItem(filterKey) === 'true'; } catch { /* this session only */ } const columns = () => S.providers.filter(provider => showAll || installed.includes(provider.id)); const roots = () => S.rootsFor(inventories, reach); - const library = () => ({ rows, inventories, errors, roots: roots(), installed, home: reach?.home || getStatus()?.paths?.volume, + const library = () => ({ rows, inventories, errors, roots: roots(), installed, agents: columns().map(provider => provider.id), home: reach?.home || getStatus()?.paths?.volume, install: options => importer.open(options) }); const inspector = T3SkillInspector.create({ api, getLibrary: library, getStatus, availability: (...args) => availability(...args), refresh: () => loadAll(true), readMarkdown, downloadMarkdown }); const importer = T3SkillImport.create({ api, getLibrary: library, refresh: () => loadAll(true), showInLibrary: names => { @@ -86,14 +89,35 @@ const T3SkillsUI = (() => { items: row.stateOptions.map(option => ({ label: option.label, description: explanations[option.value], radio: true, checked: option.value === row.state, run: () => setState(id, provider, option.value) })), footer: html`${row.statePath ? html`Saved in ${S.displayPath(row.statePath, library().home)}. ` : ''}Applies to new sessions.${provider === 'opencode' && row.effectiveStateText ? html`
${row.effectiveStateText}` : ''}` }); }; - const skillBadges = skill => html`${skill.packageManaged ? html`${icon('lock')}Package${skill.packageSource ? html` ${skill.packageSource}` : ''}` : !Object.values(skill.byProvider).some(row => row.canEdit) ? html`${icon('lock')}Read-only` : ''}${skill.metadataError ? html`Check metadata` : ''}`; + const checkUpdates = async () => { + if (checking || scan || loading.size) return; + const tracked = rows.filter(row => row.origin).map(row => ({ key: row.key, provider: row.provider, id: row.byProvider[row.provider].id, origin: row.origin })); + checking = true; paint(); + try { + for (const skill of tracked) { + const epoch = epochs[skill.provider], inventory = inventories[skill.provider]; + const current = inventory?.skills.find(row => row.id === skill.id); + if (errors[skill.provider] || current?.origin?.fingerprint !== skill.origin.fingerprint || current.origin.source !== skill.origin.source) continue; + const response = await api('/context/skills/check-update', { body: { provider: skill.provider, id: skill.id } }); + if (epochs[skill.provider] !== epoch || inventories[skill.provider] !== inventory) continue; + const update = response.ok ? response.data.update : { ...skill.origin, status: 'error', error: response.error }; + for (const data of Object.values(inventories)) for (const row of data.skills || []) { + if ((row.resolvedPath || row.path) === skill.key && row.origin?.fingerprint === skill.origin.fingerprint && row.origin.source === skill.origin.source) row.update = update; + } + rebuild(); paint(); + } + } finally { checking = false; paint(); } + }; + const updateBadge = skill => skill.update ? html`${skill.update.status === 'available' ? 'Update available' : skill.update.status === 'error' ? 'Update check failed' : 'Up to date'}` : ''; + const reviewSource = skill => importer.open({ source: skill.origin.source, names: [skill.origin.name], agents: [skill.provider], step: 2, mode: 'each' }); + const skillBadges = skill => html`${updateBadge(skill)}${skill.packageManaged ? html`${icon('lock')}Package${skill.packageSource ? html` ${skill.packageSource}` : ''}` : !Object.values(skill.byProvider).some(row => row.canEdit) ? html`${icon('lock')}Read-only` : ''}${skill.metadataError ? html`Check metadata` : ''}`; const readersLabel = root => { const readers = (root.readers || []).filter(id => installed.includes(id)); const others = readers.filter(id => id !== root.owner); return root.owner && others.length ? 'Also read by ' + M.listOf(others.map(S.nameOf)) : 'Read by ' + M.listOf(readers.map(S.nameOf)); }; const actions = () => html``; - const agentMenu = trigger => Kit.menu(trigger, { label: 'Available to', items: [{ label: 'Any agent', radio: true, checked: !agent, run: () => { agent = ''; paint(); } }, ...S.providers.filter(provider => installed.includes(provider.id)).map(provider => ({ label: provider.name, radio: true, checked: agent === provider.id, run: () => { agent = provider.id; paint(); } }))] }); + const agentMenu = trigger => Kit.menu(trigger, { label: 'Available to', items: [{ label: 'Any agent', radio: true, checked: !agent, run: () => { agent = ''; paint(); } }, ...columns().map(provider => ({ label: provider.name, radio: true, checked: agent === provider.id, run: () => { agent = provider.id; paint(); } }))] }); const groupClosed = group => collapsed.has(group.key) ? collapsed.get(group.key) : !query && !needsAttention && ['system', 'admin'].includes(group.root.scope); const attentionNotice = skill => { const problem = Object.values(skill.byProvider).find(row => row.error || row.metadataError); @@ -107,25 +131,31 @@ const T3SkillsUI = (() => { const phoneList = filtered => html`
${S.groups(filtered).map(group => html`
· ${group.rows.length}Read by ${(group.root.readers || []).filter(id => installed.includes(id)).length} agents
${groupClosed(group) ? '' : group.rows.map(skill => { const readers = installed.filter(id => !errors[id] && S.available(skill.byProvider[id])); const exceptions = installed.filter(id => skill.byProvider[id] && ['off', 'deny', 'disabled', 'ask', 'name-only', 'user-invocable-only'].includes(skill.byProvider[id].state)).map(id => S.state(skill.byProvider[id]).label + ' in ' + S.nameOf(id)); - return html``; + return html``; })}
`)}
`; const inventoryNotices = () => { const failed = new Map(); for (const id of installed) if (errors[id]) failed.set(errors[id], [...(failed.get(errors[id]) || []), id]); - const notice = (title, description) => html`
${icon('triangle-alert')}
${title}${description}
`; + const notice = (title, description, tone = 'warn') => html`
${icon(tone === 'warn' ? 'triangle-alert' : 'info')}
${title}${description}
`; return html`${[...failed].map(([error, ids]) => notice(ids.length === 1 ? S.nameOf(ids[0]) + ' skills are unavailable' : ids.length + ' agents’ skills are unavailable', html`${ids.length > 1 ? html`${M.listOf(ids.map(S.nameOf))}
` : ''}${error}`))} - ${S.providers.filter(provider => installed.includes(provider.id)).flatMap(provider => [...(inventories[provider.id]?.nativeError ? [inventories[provider.id].nativeError] : []), ...(inventories[provider.id]?.warnings || [])].map(warning => notice(provider.name + ' skills need a look', warning)))}`; + ${columns().flatMap(provider => [ + ...[...(inventories[provider.id]?.nativeError ? [inventories[provider.id].nativeError] : []), ...(inventories[provider.id]?.warnings || [])].map(warning => notice(provider.name + ' skills need a look', warning)), + ...(showAll && inventories[provider.id]?.adapter?.detected === false ? [notice(provider.name + ' isn’t detected here', 'Adding, editing and importing skills still work.', 'info')] : []), + ])}`; }; const body = () => { - const filtered = S.filter(agent && errors[agent] ? [] : rows, { query, agent, needsAttention }), problems = rows.filter(row => row.attention); + const visibleRows = hideBundled ? rows.filter(row => row.scope !== 'system') : rows; + const hidden = rows.length - visibleRows.length; + const filtered = S.filter(agent && errors[agent] ? [] : visibleRows, { query, agent, needsAttention }), problems = visibleRows.filter(row => row.attention); const failed = installed.some(id => errors[id]), scanning = Boolean(scan) || loading.size > 0; const scannedAt = Math.min(...Object.values(inventories).map(inventory => inventory.scannedAt || Date.now())); return html`
${problems.length ? html`` : ''}
${actions()}
-
${S.providers.filter(provider => installed.includes(provider.id)).map(provider => html``)}
+
${columns().map(provider => html``)}
+
${hidden ? M.plural(hidden, 'skill') + ' hidden' : 'Filters this list only'}${rows.some(row => row.origin) ? html`` : ''}
${problems.length ? html`` : ''}
${problems.slice(0, 3).map(attentionNotice)}${problems.length > 3 ? html`` : ''}${inventoryNotices()}
- ${!rows.length && scanning ? html`
${[1, 2, 3, 4].map(i => html`
${i === 1 ? 'Reading skills across your agents…' : ''}
`)}
` : !filtered.length ? html`
${icon('sparkles')}${rows.length ? 'No skills match these filters' : failed ? 'Skills couldn’t be read' : 'No skills yet'}${rows.length ? 'Try another search, agent or attention filter.' : failed ? 'Some agent inventories are unavailable. Retry the scan to check for skills.' : 'Add one, or install some from a source.'}
${rows.length ? html`` : failed ? html`` : actions()}
` : html`${table(filtered)}${phoneList(filtered)}`} + ${!rows.length && scanning ? html`
${[1, 2, 3, 4].map(i => html`
${i === 1 ? 'Reading skills across your agents…' : ''}
`)}
` : !filtered.length ? html`
${icon('sparkles')}${rows.length ? 'No skills match these filters' : failed ? 'Skills couldn’t be read' : 'No skills yet'}${rows.length ? 'Try another search or clear the library filters.' : failed ? 'Some agent inventories are unavailable. Retry the scan to check for skills.' : 'Add one, or install some from a source.'}
${rows.length ? html`` : failed ? html`` : actions()}
` : html`${table(filtered)}${phoneList(filtered)}`}
${icon('info', 'tc-icon--sm')}Changes apply to new sessions. A project’s own settings can still turn a skill off.${scanning ? 'Scanning…' : Number.isFinite(scannedAt) ? 'Scanned ' + M.relTime(scannedAt, Date.now()) : ''}
${actions()}
`; }; @@ -134,6 +164,7 @@ const T3SkillsUI = (() => { Kit.menu(trigger, { label: skill.name + ' actions', items: [ { label: 'View', icon: 'file-text', run: () => inspector.open(key) }, ...(skill.canEdit ? [{ label: 'Edit SKILL.md', icon: 'pencil', run: () => inspector.open(key, true) }] : []), + ...(skill.origin ? [{ label: skill.update?.status === 'available' ? 'Review update…' : 'Review source…', icon: 'download', run: () => reviewSource(skill) }] : []), { label: 'Copy path', icon: 'copy', run: () => Kit.copy(skill.path, trigger, 'Skill path copied') }, { label: 'Copy to…', icon: 'copy', run: () => importer.open({ source: S.directory(skill.path), names: [skill.name], step: 2 }) }, ...(skill.canRemove ? [{ sep: true }, { label: 'Remove…', icon: 'trash-2', danger: true, run: () => inspector.open(key, false).then(() => document.querySelector('[data-inspect-remove]')?.click()) }] : []), @@ -142,6 +173,7 @@ const T3SkillsUI = (() => { return { content: () => html`
${markup || (markup = body())}
`, actions, activate: navigation => { if (!started || navigation && installed.join() !== S.providers.filter(provider => getStatus()?.harnesses?.some(row => row.id === provider.id && row.installed)).map(provider => provider.id).join()) return loadAll(); }, + checkUpdates, toggleBundled: () => { hideBundled = !hideBundled; try { localStorage.setItem(filterKey, String(hideBundled)); } catch { /* this session only */ } paint(); }, count: () => rows.length, refresh: () => loadAll(true), setState, policyMenu, menu, beforeNavigate: () => importer.closeForNavigation() ? inspector.close('route') : false, view: (id, editing = false) => inspector.open(id, editing), add: async () => { @@ -153,9 +185,9 @@ const T3SkillsUI = (() => { search: value => { query = value; paint(); }, filter: agentMenu, focusSearch: () => document.getElementById('skill-search')?.focus(), select: value => { agent = value; paint(); }, attention: () => { needsAttention = !needsAttention; paint(); }, - clear: () => { query = agent = ''; needsAttention = false; paint(); const field = document.getElementById('skill-search'); if (field) field.value = ''; }, + clear: () => { query = agent = ''; needsAttention = hideBundled = false; try { localStorage.setItem(filterKey, 'false'); } catch { /* this session only */ } paint(); const field = document.getElementById('skill-search'); if (field) field.value = ''; }, group: key => { const group = S.groups(rows).find(group => group.key === key); if (group) collapsed.set(key, !groupClosed(group)); paint(); }, - columns: () => { showAll = !showAll; rebuild(); paint(); if (showAll) for (const provider of S.providers) if (!inventories[provider.id] && !loading.has(provider.id)) void loadProvider(provider.id); }, + columns: () => { showAll = !showAll; if (!showAll && !installed.includes(agent)) agent = ''; rebuild(); paint(); if (showAll) for (const provider of S.providers) if (!inventories[provider.id] && !loading.has(provider.id)) void loadProvider(provider.id); }, copy: (key, provider) => { const skill = rows.find(row => row.key === key); if (skill) importer.open({ source: S.directory(skill.path), names: [skill.name], agents: provider ? [provider] : undefined, step: 2 }); }, copyGroup: key => { const group = S.groups(rows).find(group => group.key === key); if (group) importer.open({ source: group.root.path, agents: installed.filter(id => !(group.root.readers || []).includes(id)), step: 2 }); }, paletteItems: () => [{ id: 'skills-install', label: 'Install skills from source…', desc: 'Preview and install skills into agent folders', icon: 'download', cmd: { cmd: 'skills.import' } }, { id: 'skills-new', label: 'New skill', desc: 'Write a local SKILL.md', icon: 'plus', cmd: { cmd: 'skills.add' } }, { id: 'skills-rescan', label: 'Rescan skills', desc: 'Refresh all agent inventories', icon: 'refresh-cw', cmd: { cmd: 'skills.refresh' } }, ...rows.map(row => ({ id: 'skill-' + row.key, quiet: true, label: row.name, desc: row.description, icon: 'file-text', cmd: { cmd: 'skills.view', id: row.key } }))], diff --git a/docker/setup/console.css b/docker/setup/console.css index 0629ef8..d81e40d 100644 --- a/docker/setup/console.css +++ b/docker/setup/console.css @@ -928,6 +928,15 @@ html.tc-locked { overflow: hidden; } .tc-skill-name { appearance: none; padding: 0; border: 0; background: none; color: var(--fg); text-align: left; font: 500 14px/20px var(--font-sans); cursor: pointer; overflow-wrap: anywhere; } .tc-skill-name:hover { text-decoration: underline; text-underline-offset: 3px; } .tc-mx-row[data-highlight], .tc-prow[data-highlight] { background: var(--accent-soft); } +.tc-sk-options { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-2); min-height: 44px; font-size: 12px; } +.tc-sk-options label { display: inline-flex; align-items: center; min-height: 44px; cursor: pointer; } +.tc-sk-options .tc-switch::before { content: ""; position: absolute; inset: -14px -9px; } +.tc-sk-other-agents { display: none; } +.tc-sk-other-agents[data-empty] { display: inline-flex; } +@media (max-width: 1023.98px) { .tc-sk-other-agents { display: inline-flex; } } +.tc-sk-options .tc-hint { margin: 0; } +.tc-prow-name .tc-badge { margin-left: var(--space-2); vertical-align: middle; } +.tc-skill-origin { display: block; overflow-wrap: anywhere; } .tc-sk-library-foot { display: flex; gap: var(--space-2); align-items: center; flex-wrap: wrap; font-size: 12px; line-height: 18px; color: var(--fg-muted); } .tc-sk-library-foot > span:first-child { display: inline-flex; align-items: center; gap: var(--space-2); flex: 1 1 28rem; } .tc-sk-scan-time { margin-left: auto; } diff --git a/docker/setup/server.mjs b/docker/setup/server.mjs index d8dd2cf..6050214 100644 --- a/docker/setup/server.mjs +++ b/docker/setup/server.mjs @@ -2150,7 +2150,7 @@ const ROUTES = ["/login", "/logout", "/hello", "/status", "/pair", "/revoke", "/ "/toolchains/install", "/toolchains/update", "/toolchains/uninstall", "/toolchains/cancel", "/auth/apikey/remove", "/auth/apikey", "/auth/signin", "/auth/session", "/auth/code", "/auth/cancel", "/source-control/signin", "/source-control/signout", - "/providers", "/updates/check", + "/providers", "/updates/check", "/context/skills/check-update", "/packages/install", "/packages/update", "/packages/uninstall", "/packages/cancel", "/packages/registry", "/packages/versions", "/packages/info", "/context/agents-md", "/context/skills/reach", "/context/skills/read", "/context/skills/create", "/context/skills/save", "/context/skills/remove", "/context/skills/state", "/context/skills/source", "/context/skills/plan", "/context/skills/install", "/context/skills/discard", "/context/skills"]; @@ -2650,11 +2650,12 @@ const server = createServer(async (req, res) => { } } - if (req.method === "POST" && ["/context/skills/create", "/context/skills/save", "/context/skills/remove", "/context/skills/state", "/context/skills/source", "/context/skills/plan", "/context/skills/install", "/context/skills/discard"].includes(route)) { + if (req.method === "POST" && ["/context/skills/create", "/context/skills/save", "/context/skills/remove", "/context/skills/state", "/context/skills/check-update", "/context/skills/source", "/context/skills/plan", "/context/skills/install", "/context/skills/discard"].includes(route)) { try { const input = JSON.parse((await readBody(req, 6 * 64 * 1024 + 4096)) || "{}"); if (!input || typeof input !== "object" || Array.isArray(input)) return sendJson(res, 400, { ok: false, error: "Expected a skill operation object." }); - const result = route === "/context/skills/source" ? await skillsService.previewSource(input.source) + const result = route === "/context/skills/check-update" ? await skillsService.checkUpdate(input.provider, input.id) + : route === "/context/skills/source" ? await skillsService.previewSource(input.source) : route === "/context/skills/plan" ? await skillsService.planImport(input.previewId, input.skills, input.providers) : route === "/context/skills/install" ? await skillsService.installImport(input.previewId, input.planId, input.replace) : route === "/context/skills/discard" ? await skillsService.discardImport(input.previewId) @@ -2662,7 +2663,7 @@ const server = createServer(async (req, res) => { : route === "/context/skills/save" ? await skillsService.save(input.provider, input.id, input.revision, input.content) : route === "/context/skills/state" ? await skillsService.setState(input.provider, input.id, input.stateRevision, input.enabled, input.state) : await skillsService.remove(input.provider, input.id, input.revision, input.removalRevision); - if (!["/context/skills/source", "/context/skills/plan", "/context/skills/discard"].includes(route)) recordEvent("context.saved", route === "/context/skills/install" ? "Imported local skills; review individual installation results" : route === "/context/skills/remove" ? "Removed a local skill installation" : route === "/context/skills/state" ? "Changed native skill availability" : "Saved a local skill"); + if (!["/context/skills/check-update", "/context/skills/source", "/context/skills/plan", "/context/skills/discard"].includes(route)) recordEvent("context.saved", route === "/context/skills/install" ? "Imported local skills; review individual installation results" : route === "/context/skills/remove" ? "Removed a local skill installation" : route === "/context/skills/state" ? "Changed native skill availability" : "Saved a local skill"); return sendJson(res, 200, result); } catch (error) { const status = { invalid: 400, missing: 404, ENOENT: 404, conflict: 409, EEXIST: 409, too_large: 413, unsupported: 403, EACCES: 403, EPERM: 403 }[error?.code] || (error instanceof SyntaxError ? 400 : 500); diff --git a/docker/setup/skills.mjs b/docker/setup/skills.mjs index 7a4287d..413305d 100644 --- a/docker/setup/skills.mjs +++ b/docker/setup/skills.mjs @@ -8,6 +8,9 @@ import { listCodexSkills, writeCodexSkillConfig } from "./skills/codex.mjs"; import { codexConfigPath, configSnapshot, stateRevision, serializeState } from "./skills/state.mjs"; import { createSkillFileManager, skillRevision } from "./skills/files.mjs"; import { createSkillPolicies } from "./skills/policies.mjs"; +import { readSkillOrigin, skillFingerprint } from "./skills/origins.mjs"; +import { adapterDetection } from "./skills/adapters.mjs"; +import { resolveSkillSource } from "./skills/sources.mjs"; import { createSkillImporter } from "./skills/imports.mjs"; import { skillReach } from "./skills/reach.mjs"; @@ -147,7 +150,7 @@ export async function scanSkillFiles(provider, options) { export function createSkillsService({ home, env = process.env, io = fs, nativeCodex = listCodexSkills, nativeCodexWrite = writeCodexSkillConfig, inspectClaude, inspectOpenCode, getHarness = async () => null, ttlMs = 15000, limits, resolveSource } = {}) { home = path.resolve(home || env.HOME || "/home/t3"); - const cache = new Map(); + const cache = new Map(), updateStates = new Map(), updateChecks = new Map(); const policies = createSkillPolicies({ home, env, io, getHarness, inspectClaude, inspectOpenCode, invalidate: () => cache.clear() }); const load = async (provider) => { const scanned = await scanSkillFiles(provider, { home, env, io, limits }); @@ -196,6 +199,15 @@ export function createSkillsService({ home, env = process.env, io = fs, nativeCo } catch (error) { nativeError = summaryError(error); } } const policy = await fileManager.policyFor(provider); + const adapter = await adapterDetection(provider, { home, env, io }); + for (const row of scanned.skills) { + const origin = await readSkillOrigin(io, row, home); + if (origin && policy.capabilities(row).canEdit) { + row.origin = origin; + const checked = updateStates.get(row.resolvedPath); + if (checked?.fingerprint === origin.fingerprint && checked.source === origin.source) row.update = checked; + } + } return { ok: true, provider, canCreate: policy.canCreate, creationTargets: policy.creationTargets, providers: skillProviders(), skills: scanned.skills.map((row) => { if (row.stateAuthority === "user-policy") return { ...row, ...policy.capabilities(row) }; const reason = provider !== "codex" ? nativeError || "Native policy controls are currently supported for Claude Code and OpenCode; native availability controls for Codex." @@ -206,7 +218,7 @@ export function createSkillsService({ home, env = process.env, io = fs, nativeCo return { ...row, ...policy.capabilities(row), canToggle: !reason, stateReadOnlyReason: reason, stateRevision: !reason ? stateRevision(config, row) : null }; }).sort((a, b) => a.name.localeCompare(b.name) || a.path.localeCompare(b.path)), - roots: scanned.roots, coverage, nativeError, warnings: scanned.warnings, scannedAt: Date.now(), + roots: scanned.roots, coverage, nativeError, warnings: scanned.warnings, scannedAt: Date.now(), adapter, coverageText: coverage === "policy" ? "Known global skill folders with native user policy settings checked by the managed provider. Project/session and managed policies can further restrict availability." : coverage === "native" ? "Managed Codex inventory in the setup environment, with known local folders included. Project skills are excluded." : "Known global skill folders only. Provider availability, plugins and additional configured locations are not verified.", @@ -270,6 +282,31 @@ export function createSkillsService({ home, env = process.env, io = fs, nativeCo }; const fileManager = createSkillFileManager({ home, env, io, rootsFor: (provider) => skillRoots(provider, { home, env }), metadata: skillMetadata, read, invalidate: () => cache.clear() }); const importer = createSkillImporter({ home, env, io, metadata: skillMetadata, policyFor: fileManager.policyFor, invalidate: () => cache.clear(), resolveSource }); + const checkUpdate = async (provider, id) => { + const row = await read(provider, id); + if (!row.origin || !row.canEdit) throw problem("unsupported", "This skill has no tracked repository source. Re-import it from its repository to enable update checks."); + const key = row.resolvedPath; + if (updateChecks.has(key)) return updateChecks.get(key); + if (updateChecks.size >= 3) throw problem("conflict", "Other repository checks are still running. Try again when they finish."); + const pending = (async () => { + let update; + try { + const snapshot = await (resolveSource || resolveSkillSource)(row.origin.source, { home, env, metadata: skillMetadata }); + const skill = snapshot.skills?.find((entry) => entry.name === row.origin.name); + if (!skill) throw new Error("The installed skill was not found in this source. It may have been renamed or removed."); + update = { ...row.origin, status: skillFingerprint(skill) === row.origin.fingerprint ? "current" : "available", checkedAt: Date.now() }; + } catch (error) { update = { ...row.origin, status: "error", error: summaryError(error), checkedAt: Date.now() }; } + // A replaced or removed installation must not inherit a stale check result. + const current = await readSkillOrigin(io, row, home); + if (!current || current.source !== row.origin.source || current.fingerprint !== row.origin.fingerprint) throw problem("conflict", "The installation changed while checking. Refresh and check again."); + updateStates.delete(key); updateStates.set(key, update); + if (updateStates.size > 1024) updateStates.delete(updateStates.keys().next().value); + cache.clear(); + return { ok: true, id, update }; + })(); + updateChecks.set(key, pending); + try { return await pending; } finally { updateChecks.delete(key); } + }; const reach = ({ refresh = false } = {}) => { const key = "reach", hit = cache.get(key); if (hit && (hit.pending || (!refresh && Date.now() - hit.at < ttlMs))) return hit.promise; @@ -278,5 +315,5 @@ export function createSkillsService({ home, env = process.env, io = fs, nativeCo .then(result => { entry.pending = false; entry.at = Date.now(); return { ...result, home }; }, error => { cache.delete(key); throw error; }); cache.set(key, entry); return entry.promise; }; - return { list, read, reach, setState, create: fileManager.create, save: fileManager.save, remove: fileManager.remove, ...importer }; + return { list, read, reach, checkUpdate, setState, create: fileManager.create, save: fileManager.save, remove: fileManager.remove, ...importer }; } diff --git a/docker/setup/skills/adapters.mjs b/docker/setup/skills/adapters.mjs new file mode 100644 index 0000000..59fba59 --- /dev/null +++ b/docker/setup/skills/adapters.mjs @@ -0,0 +1,64 @@ +// Provider directory adapters: global/project skill locations and install +// detection hints. Table shape informed by skills-hub's tool registry (MIT, +// https://github.com/qufei1993/skills-hub); global locations stay aligned +// with skillRoots() in ../skills.mjs, which remains canonical for scanning. +// Detection is advisory only: an undetected tool stays fully manageable +// through its files, unlike registries that refuse unknown tools. +import path from "node:path"; + +const fail = (code, message) => Object.assign(new Error(message), { code }); + +// projectDir is relative to a project root; null means project scope is +// unverified for that provider. Codex, Cursor, OpenCode and Antigravity +// share the `.agents/skills` project directory, so one project's skills can +// appear in another provider's inventory. +const TABLE = Object.freeze({ + claude: Object.freeze({ label: "Claude Code", projectDir: ".claude/skills" }), + codex: Object.freeze({ label: "Codex", projectDir: ".agents/skills" }), + opencode: Object.freeze({ label: "OpenCode", projectDir: ".agents/skills" }), + cursor: Object.freeze({ label: "Cursor", projectDir: ".agents/skills" }), + grok: Object.freeze({ label: "Grok Build", projectDir: null }), + muse: Object.freeze({ label: "Muse Code", projectDir: null }), + antigravity: Object.freeze({ label: "Antigravity", projectDir: ".agents/skills" }), +}); + +export function adapterProviders() { + return Object.entries(TABLE).map(([id, { label }]) => ({ id, name: label })); +} + +// Resolve the global skills directory and the detection hint under home, +// honoring the same environment overrides as skillRoots(). +export function skillAdapter(provider, { home, env = {} } = {}) { + const entry = TABLE[provider]; + if (!entry) throw fail("invalid", "Unknown skill provider."); + const override = (key, fallback) => path.resolve(home, String(env[key] || "").trim() || fallback); + const config = override("XDG_CONFIG_HOME", ".config"); + const root = { + claude: override("CLAUDE_CONFIG_DIR", ".claude"), + codex: override("CODEX_HOME", ".codex"), + opencode: override("OPENCODE_CONFIG_DIR", path.join(config, "opencode")), + cursor: path.join(home, ".cursor"), + grok: override("GROK_HOME", ".grok"), + muse: path.join(config, "muse"), + antigravity: path.join(home, ".gemini", "config"), + }[provider]; + return { + id: provider, + label: entry.label, + globalDir: path.join(root, "skills"), + projectDir: entry.projectDir, + detectDir: root, + }; +} + +// Stat the detection directory. Filesystem errors mean "not detected", +// never a failure: inventory and management work regardless. +export async function adapterDetection(provider, { home, env = {}, io }) { + const adapter = skillAdapter(provider, { home, env }); + try { + const stat = await io.stat(adapter.detectDir); + return { ...adapter, detected: stat.isDirectory() }; + } catch { + return { ...adapter, detected: false }; + } +} diff --git a/docker/setup/skills/imports.mjs b/docker/setup/skills/imports.mjs index e83688f..0695f50 100644 --- a/docker/setup/skills/imports.mjs +++ b/docker/setup/skills/imports.mjs @@ -1,7 +1,8 @@ -// Previewed source snapshots become ordinary local skills. No package manifest. +// Previewed source snapshots become local skills with repository provenance. import path from "node:path"; import { randomBytes } from "node:crypto"; import { resolveSkillSource } from "./sources.mjs"; +import { ORIGIN_FILE, skillFingerprint } from "./origins.mjs"; import { removalPlan, serialized } from "./files.mjs"; const fail = (code, message) => Object.assign(new Error(message), { code }); @@ -74,7 +75,7 @@ export function createSkillImporter({ home, env, io, metadata, policyFor, invali return { ok: true, planId, destinations: destinations.map(({ root, rootPath, revision, ...entry }) => entry), canInstall: destinations.every((entry) => entry.action !== "blocked") }; } finally { snapshot.planning = false; } }; - const publish = async (entry, skill) => serialized(path.join(entry.directory, "SKILL.md"), async () => { + const publish = async (entry, skill, snapshot) => serialized(path.join(entry.directory, "SKILL.md"), async () => { for (const provider of entry.also) { const alias = await inspect(provider, skill); if (alias.directory !== entry.directory || alias.action !== entry.action || alias.revision !== entry.revision) throw fail("conflict", "A shared provider folder changed after review."); @@ -86,12 +87,17 @@ export function createSkillImporter({ home, env, io, metadata, policyFor, invali const staging = await io.mkdtemp(path.join(entry.root, ".t3-skill-import-")); let backup = null, reserved = false; try { + if (skill.files.some((file) => file.name === ORIGIN_FILE)) throw fail("conflict", "This source contains reserved skill tracking metadata."); for (const file of skill.files) { const output = path.join(staging, file.name); await io.mkdir(path.dirname(output), { recursive: true, mode: 0o700 }); await io.writeFile(output, file.data, { flag: "wx", mode: file.mode }); await io.chmod(output, file.mode); } + if (snapshot.sourceType === "git") { + // A source cannot supply or overwrite the console's provenance file. + await io.writeFile(path.join(staging, ORIGIN_FILE), JSON.stringify({ version: 1, source: snapshot.source, name: skill.name, fingerprint: skillFingerprint(skill) }) + "\n", { flag: "wx", mode: 0o600 }); + } // Recheck after staging too, so supporting-file edits invalidate replacement. for (const provider of [entry.provider, ...entry.also]) { const checked = await inspect(provider, skill); @@ -141,7 +147,7 @@ export function createSkillImporter({ home, env, io, metadata, policyFor, invali } const results = []; for (const entry of plan.destinations) { - try { results.push(await publish(entry, snapshot.skills.find((skill) => skill.name === entry.name))); } + try { results.push(await publish(entry, snapshot.skills.find((skill) => skill.name === entry.name), snapshot)); } catch (error) { results.push({ ...entry, status: "failed", error: error.message }); } } snapshot.result = { ok: true, results: results.map(({ root, rootPath, revision, ...entry }) => entry) }; diff --git a/docker/setup/skills/origins.mjs b/docker/setup/skills/origins.mjs new file mode 100644 index 0000000..fd1a129 --- /dev/null +++ b/docker/setup/skills/origins.mjs @@ -0,0 +1,29 @@ +// Import provenance travels atomically with the installed folder. Inventory never fetches sources. +import path from "node:path"; +import { constants } from "node:fs"; +import { createHash } from "node:crypto"; +import { parseSkillSource } from "./sources.mjs"; + +export const ORIGIN_FILE = ".t3-skill-source.json"; +export const skillFingerprint = (skill) => { + const files = skill.files.map((file) => [file.name, Boolean(file.mode & 0o100), createHash("sha256").update(file.data).digest("hex")]); + files.sort((a, b) => a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0); + return createHash("sha256").update(JSON.stringify(files)).digest("hex"); +}; +export async function readSkillOrigin(io, row, home) { + if (!row.resolvedPath || row.scope !== "user" || row.source === "Plugin" || path.basename(row.path) !== "SKILL.md") return null; + let handle; + try { + // Refuse a retargeted skill before reading its adjacent metadata. + if (await io.realpath(row.path) !== row.resolvedPath) return null; + handle = await io.open(path.join(path.dirname(row.resolvedPath), ORIGIN_FILE), constants.O_RDONLY | constants.O_NONBLOCK | constants.O_NOFOLLOW); + const stat = await handle.stat(); + if (!stat.isFile() || stat.size > 8192) return null; + const buffer = Buffer.alloc(8193), { bytesRead } = await handle.read(buffer, 0, buffer.length, 0); + if (bytesRead > 8192) return null; + const value = JSON.parse(buffer.toString("utf8", 0, bytesRead)); + if (value.version !== 1 || typeof value.source !== "string" || parseSkillSource(value.source, home).type !== "git" || typeof value.name !== "string" || !/^[a-z0-9]+(-[a-z0-9]+)*$/.test(value.name) || value.name.length > 64 || !/^[a-f0-9]{64}$/.test(value.fingerprint)) return null; + return { source: value.source, name: value.name, fingerprint: value.fingerprint }; + } catch { return null; } + finally { await handle?.close(); } +} diff --git a/docker/setup/skills/sources.mjs b/docker/setup/skills/sources.mjs index 2daf890..e5e93b0 100644 --- a/docker/setup/skills/sources.mjs +++ b/docker/setup/skills/sources.mjs @@ -260,7 +260,7 @@ export async function resolveSkillSource(value, { home, metadata, env = process. const relative = file.name.slice(prefix.length), parts = relative.split("/"); for (let i = 1; i <= parts.length; i++) treeEntries.add(parts.slice(0, i).join("/")); } - if (treeEntries.size > 1024) throw fail("too_large", "This skill exceeds the 1024-entry management limit. Use a smaller skill folder."); + if (treeEntries.size + (parsed.type === "git" ? 1 : 0) > 1024) throw fail("too_large", "This skill exceeds the 1024-entry management limit. Use a smaller skill folder."); const copied = []; for (const file of children) { const name = file.name.slice(prefix.length); @@ -274,6 +274,6 @@ export async function resolveSkillSource(value, { home, metadata, env = process. if (preferredPaths?.has(entry.name)) foundPreferred = true; } if (!skills.length) throw fail(oversized ? "too_large" : "invalid", oversized ? "No matching skill could be imported. Skill Markdown must fit within 64 KiB; use a smaller skill or manage oversized skills directly." : parsed.filter ? "That skill was not found in this source." : "No valid SKILL.md files found. Choose a skill repository/folder or paste a direct Markdown link."); - return { source: value, sourceInfo, skills, warnings: [...new Set(warnings)].slice(0, 30) }; + return { source: value, sourceInfo, sourceType: parsed.type, skills, warnings: [...new Set(warnings)].slice(0, 30) }; } finally { await cleanup(); } } diff --git a/docs/context-management.md b/docs/context-management.md index e800631..4edfbf6 100644 --- a/docs/context-management.md +++ b/docs/context-management.md @@ -125,9 +125,22 @@ and runtime metadata, as described below. Cursor, Grok, Muse and Antigravity sho **Found** with a discovery-only explanation. Plugin inventories and configured extra locations are outside the basic known-folder inventory. +**Hide bundled skills** hides entries identified as system skills, such as those +shipped with Codex. It only filters this list; the agent can still use them. The +choice is remembered in this browser, and the count shows how many are hidden. +User, administrator and plugin installations remain visible unless the provider +identifies them as system skills. + Inventories load in parallel when the Skills tab opens. **Rescan** refreshes all installed agents after external changes. One failed provider leaves the others usable. Concurrent requests share a lookup and results are cached for 15 seconds; regular `/status` polling does not scan skills. +Each inventory also reports its provider adapter: the global skill folder, +the project skill folder where one is verified (Codex, Cursor, OpenCode and +Antigravity share `.agents/skills` inside a project), and whether the tool +itself is detected. Detection never gates management: an undetected tool +shows a note, and adding, editing and importing still work. Use the `+N` header +button, or **Show other agents** on phones, to include these folders. The installer +only uses an undetected agent’s folder after you explicitly select that agent. Bodies load separately through authenticated, discovered-installation IDs. `GET /context/skills/read` adds `files: [{ name, bytes, executable, linked }]` and `fileCount` (or `filesError` if inspection is bounded/unavailable); the numeric @@ -288,10 +301,24 @@ Native path references: [Cursor skills](https://cursor.com/docs/skills), [Muse skills](https://meta-models.github.io/muse-code-sdk/next/guides/extend/skills/), and [Antigravity skills](https://antigravity.google/docs/skills). -Re-importing the source is the update flow: review a fresh snapshot and explicitly -replace each existing local folder, including removed/renamed supporting files. -The console does not remember origins, schedule updates or maintain a source -manifest. Use **View → Remove** to delete an imported local skill. Files tracked +Repository installs remember their source and a fingerprint of the installed +files in `.t3-skill-source.json` inside the skill folder. **Check for updates** +on the Skills tab checks tracked repository installs on demand. It compares the +skill's complete source contents and executable flags, so unrelated repository +commits do not trigger an update. Ordinary inventory refreshes never fetch sources. +Checks report **Up to date**, **Update available**, or **Update check failed**. +These results last until the setup service restarts; source tracking survives restarts. + +Open a skill and choose **Review update** (or **Review source**) to preview its +repository with that skill and its owning agent selected. Compatibility readers +do not receive additional copies by default. Explicitly confirm replacing the existing +folder, including local edits and removed/renamed supporting files. Checking alone +never changes installed files, and local edits do not count as upstream updates. +A ref pinned to a commit continues to check that commit. There are no scheduled +checks or automatic replacements. Earlier installs and imports from local folders +or direct Markdown links need a repository re-import to enable tracking. + +Use **View → Remove** to delete an imported local skill. Files tracked in the upstream skills CLI lock remain read-only here, including during import; manage those packages with their package manager. Unreadable locks, known plugin/system roots (including Grok/Antigravity plugin folders and Codex admin skills), linked diff --git a/docs/experiments/skills-hub.md b/docs/experiments/skills-hub.md new file mode 100644 index 0000000..92810f2 --- /dev/null +++ b/docs/experiments/skills-hub.md @@ -0,0 +1,67 @@ +# Spike: skills-hub CLI for skills/plugin support + +Branch: `exp/skills-hub-sync` (off `main`). Disposable test only, no repo changes. +Test home: `/tmp/sh-hub-test`, CLI 1.0.1 (`skillshub-cli`), glibc 2.41 satisfies CLI's 2.39 floor. + +## What skills-hub is + +Central library (`~/.skillshub/`) + `deploy` to 48 tool adapters +(`agents list`: global dir, project dir, detection dir, `sync_mode`). +Commands are JSON-stable: `skills install/list/search/show/status/check`, +`skills deploy/undeploy [--agent …] [--project …] [--dry-run]`, +`skills update/check --all`, `skills adopt --dry-run/--yes`, `tag`, `remove` +(recycle bin). Install script is SHA-256 verified, `~/.local/bin`, no node/npm. + +## Experiment: ponytail + +- `skills install https://github.com/DietrichGebert/ponytail` returns + `MULTI_SKILLS` with 6 candidates (`skills/ponytail`, `-audit`, `-debt`, + `-gain`, `-help`, `-review`). One `--subpath` each — same per-skill + friction our preview has. +- Library records git source + revision + content hash; `check` reports + `update_available` without touching targets. Closest existing equivalent + to our `origins.mjs` fingerprint + `check-update` model. +- `deploy --dry-run` previews; real deploy defaults to **symlink** + (`mode: symlink`) from each tool dir to the central copy, not a copy. +- Deploy **refuses unknown tools**: `AGENT_NOT_FOUND … not_installed` + until the detection dir exists (created `.claude`, `.codex`, + `.config/opencode` to proceed). Our console manages files regardless + of detection — UX gap to note. +- Conflict: planting an unmanaged `skills/ponytail/SKILL.md` then + deploying gives `TARGET_CONFLICT … unmanaged_target`; `adopt --dry-run` + is the recovery path. Mirrors our replacement-consent + fingerprint + checks, but centralized. + +## What it can't do (plugin gap remains) + +- No plugin/marketplace/hook handling: no `marketplace add`, no + `.claude-plugin` / `.codex-plugin` hooks, no `opencode.json` plugins + array, no Cursor `hooks.json` merge, no Grok enable list. Deploying + all 6 ponytail skills still yields prompt-only skills, same as our copy. +- No native policy controls: no Claude `skillOverrides`, OpenCode + `permission.skill`/`permissions`, Codex `skills/config/write` + enable/disable. Complementary to, not a replacement for, + `docker/setup/skills/policies.mjs` + `native.mjs`. +- No bundled/system/plugin inventory distinction beyond + managed-vs-unmanaged; our hide-bundled + read-only plugin roots + still add value. + +## Options for this repo + +1. **Adapter table**: borrow the 48-entry global/project/detection paths + (`tool_adapters/mod.rs`) to grow beyond our 7 providers. MIT licensed. + → Landed in part: `docker/setup/skills/adapters.mjs` + `adapter` on the + inventory + `tests/skill-adapters.test.mjs` (the update half came via + `feat/skill-source-updates`, below). +2. **Library + symlink deploy**: single update point + `check/update --all` + beats per-provider copies, but changes persistence (central lib must be + on the state volume), edit/remove semantics (symlink vs copy), and the + no-shell setup-page story (CLI vs native service). + → Landed in copy-model form via `feat/skill-source-updates`: + `.t3-skill-source.json` provenance + on-demand `check-update`, no + central library, no symlinks. +3. **CLI bridge**: shell to `skillshub-cli --json` like `native.mjs` shells + to managed CLIs — bounded, allowlisted subcommands, dry-run first. + Still leaves the ponytail plugin layer (hooks/commands/marketplace) open. + +Not committing to any option here; this file is the spike record. diff --git a/docs/media/skills/adapter-note-phone-dark.png b/docs/media/skills/adapter-note-phone-dark.png new file mode 100644 index 0000000..7bb9341 Binary files /dev/null and b/docs/media/skills/adapter-note-phone-dark.png differ diff --git a/docs/media/skills/adapter-note-phone-light.png b/docs/media/skills/adapter-note-phone-light.png new file mode 100644 index 0000000..efe96bb Binary files /dev/null and b/docs/media/skills/adapter-note-phone-light.png differ diff --git a/docs/media/skills/phone-bundled-hidden-dark.png b/docs/media/skills/phone-bundled-hidden-dark.png new file mode 100644 index 0000000..88919c1 Binary files /dev/null and b/docs/media/skills/phone-bundled-hidden-dark.png differ diff --git a/docs/media/skills/phone-bundled-hidden-light.png b/docs/media/skills/phone-bundled-hidden-light.png new file mode 100644 index 0000000..6247a58 Binary files /dev/null and b/docs/media/skills/phone-bundled-hidden-light.png differ diff --git a/docs/media/skills/phone-repository-review-dark.png b/docs/media/skills/phone-repository-review-dark.png new file mode 100644 index 0000000..d6e4768 Binary files /dev/null and b/docs/media/skills/phone-repository-review-dark.png differ diff --git a/docs/media/skills/phone-repository-review-light.png b/docs/media/skills/phone-repository-review-light.png new file mode 100644 index 0000000..b249abb Binary files /dev/null and b/docs/media/skills/phone-repository-review-light.png differ diff --git a/docs/media/skills/phone-repository-update-dark.png b/docs/media/skills/phone-repository-update-dark.png new file mode 100644 index 0000000..ab3a354 Binary files /dev/null and b/docs/media/skills/phone-repository-update-dark.png differ diff --git a/docs/media/skills/phone-repository-update-light.png b/docs/media/skills/phone-repository-update-light.png new file mode 100644 index 0000000..995fad8 Binary files /dev/null and b/docs/media/skills/phone-repository-update-light.png differ diff --git a/scripts/skills-ui-audit.js b/scripts/skills-ui-audit.js index a6e74cc..cd91260 100644 --- a/scripts/skills-ui-audit.js +++ b/scripts/skills-ui-audit.js @@ -58,9 +58,11 @@ const checkSelect = async (page, select, theme) => { const page = await browser.newPage({ viewport: { width, height }, colorScheme: theme, reducedMotion: 'reduce' }); await page.addInitScript(mode => localStorage.setItem('t3-console-theme', mode), theme); const api = createSkillsFixtures(), status = statusFor('skills'), errors = [], writes = [], gets = {}; + api.inventories.antigravity.adapter = { detected: false }; page.setDefaultTimeout(8000); page.on('pageerror', error => { errors.push(error.message); console.error('Browser error:', error.message); }); let stateGate, stateStarted, planGate, planStarted, sourceGate, sourceStarted, readGate, readStarted, installGate, installStarted, saveGate, saveStarted, reachGate, reachStarted; + let updateStatus = 'available', updateGate, updateStarted; let stateFail = false, saveFail = false, partial = false, blocked = false, expire = false, providerError = null, loseInstallResponse = false; await page.route('**/*', async route => { const request = route.request(), url = new URL(request.url()), method = request.method(); @@ -70,6 +72,11 @@ const checkSelect = async (page, select, theme) => { if (method === 'GET') gets[url.pathname + url.search] = (gets[url.pathname + url.search] || 0) + 1; else writes.push({ path: url.pathname, input }); if (at('/skills') && (providerError === 'all' || url.searchParams.get('provider') === providerError)) return route.fulfill({ status: 503, json: { ok: false, error: 'Fixture agent is temporarily unavailable' } }); + if (at('/check-update')) { + if (updateGate) { updateStarted.resolve(); await updateGate.promise; updateGate = null; } + const update = { status: updateStatus, error: updateStatus === 'error' ? 'Fixture repository unavailable' : undefined }; + return route.fulfill({ json: { ok: true, id: input.id, update } }); + } if (at('/state')) { if (stateGate) { stateStarted.resolve(); await stateGate.promise; stateGate = null; } if (stateFail) return route.fulfill({ status: 409, json: { ok: false, error: 'Settings changed outside the console. Rescan before trying again.' } }); @@ -139,6 +146,14 @@ const checkSelect = async (page, select, theme) => { assert.equal(await target.locator('.tc-mx-row').count(), 1); await target.locator('[data-cmd="skills.agent"][data-provider=""]').click(); } + const beforeFilter = writes.length; + await target.getByRole('switch', { name: 'Hide bundled skills', exact: true }).click(); + assert.equal(await target.locator('.tc-mx-group-toggle').filter({ hasText: 'Codex system skills' }).count(), 0); + assert.equal(await target.locator('#skill-filter-help').innerText(), '4 skills hidden'); + assert.equal(writes.length, beforeFilter, 'hiding bundled skills never writes provider settings'); + await shot('bundled-hidden'); + await target.getByRole('switch', { name: 'Hide bundled skills', exact: true }).click(); + assert.equal(await target.locator('.tc-mx-row').count(), 18); await shot('library'); await checkNoticeContrast(visible(target, '.tc-sk-notices .tc-notice-desc, .tc-sk-phone-notice > span > span').first()); const deep = () => visible(target, '[data-cmd="skills.view"]').filter({ hasText: 'deep-research' }); @@ -237,10 +252,10 @@ const checkSelect = async (page, select, theme) => { assert.equal(await target.getByText('No skills yet', { exact: true }).count(), 0, 'discovery failures must not masquerade as an empty library'); providerError = null; await target.getByRole('button', { name: 'Retry scan', exact: true }).click(); await target.locator('.tc-mx-row').first().waitFor(); - await target.locator('[data-cmd="skills.columns"]').click(); + await visible(target, '[data-cmd="skills.columns"]').click(); await target.waitForFunction(() => document.querySelectorAll('.tc-mx-agent').length === 7); assert.equal(await target.locator('.tc-mx-agent[data-uninstalled]').count(), 2); - await target.locator('[data-cmd="skills.columns"]').click(); + await visible(target, '[data-cmd="skills.columns"]').click(); // A closed source request must release its late snapshot, never reopen. sourceGate = deferred(); sourceStarted = deferred(); await visible(target, '[data-cmd="skills.import"]').click(); @@ -403,6 +418,54 @@ const checkSelect = async (page, select, theme) => { await target.locator('.tc-skill-inspector').waitFor({ state: 'hidden' }); assert.equal(writes.find(write => write.path.endsWith('/create')).input.content, upload); } + // A tracked physical skill may be visible in several agent inventories. + // Check it once and keep review scoped to its owning folder. + const origin = { source: 'vercel-labs/skills', name: 'frontend-design', fingerprint: 'a'.repeat(64) }; + for (const inventory of Object.values(api.inventories)) for (const skill of inventory.skills) { + if (skill.name === origin.name && skill.path.includes('/.claude/')) skill.origin = origin; + } + await target.locator('[data-cmd="skills.refresh"]').click(); + const checkUpdates = target.getByRole('button', { name: 'Check for updates', exact: true }); + await checkUpdates.waitFor(); + await checkUpdates.click(); + await visible(target, '.tc-badge').filter({ hasText: 'Update available' }).waitFor(); + assert.equal(writes.filter(write => write.path.endsWith('/check-update')).length, 1); + await visible(target, '[data-cmd="skills.view"]').filter({ hasText: 'frontend-design' }).scrollIntoViewIfNeeded(); + await shot('repository-update'); + const trackedSkill = visible(target, '[data-cmd="skills.view"]').filter({ hasText: 'frontend-design' }); + await trackedSkill.click(); await target.locator('[data-inspect-review-source]').waitFor(); + await target.locator('[data-inspect-review-source]').click(); + await target.locator('[data-import-replace]').waitFor(); + assert.equal(await target.locator('[data-import-mode=each]').getAttribute('aria-pressed'), 'true'); + const review = writes.filter(write => write.path.endsWith('/plan')).at(-1).input; + assert.deepEqual(review.skills, ['frontend-design']); + assert.deepEqual(review.providers, ['claude']); + assert.equal(await target.locator('[data-import-replace]').isChecked(), false, 'source review retains explicit replacement consent'); + await shot('repository-review'); await close(); + updateStatus = 'error'; await checkUpdates.click(); + await visible(target, '.tc-badge').filter({ hasText: 'Update check failed' }).waitFor(); + updateStatus = 'current'; await checkUpdates.click(); + await visible(target, '.tc-badge').filter({ hasText: 'Up to date' }).waitFor(); + updateGate = deferred(); updateStarted = deferred(); + await checkUpdates.click(); await updateStarted.promise; + await target.locator('[data-cmd="skills.refresh"]').click(); + await checkUpdates.waitFor({ state: 'hidden' }); + updateGate.resolve(); + await checkUpdates.waitFor(); + assert.equal(await target.locator('.tc-badge').filter({ hasText: 'Up to date' }).count(), 0, 'late checks cannot decorate a rescanned installation'); + // Folder management stays available for undetected tools, including on phones. + await visible(target, '[data-cmd="skills.columns"]').click(); + await target.waitForFunction(() => [...document.querySelectorAll('.tc-notice-title')].some(node => node.textContent.includes('Antigravity isn’t detected'))); + await visible(target, '.tc-sk-provider-notice').filter({ hasText: 'Antigravity' }).scrollIntoViewIfNeeded(); + await shot('undetected-agent'); + await openSource(); + await target.locator('[data-import-skill="frontend-design"]').check(); + await next().click(); + await target.locator('[data-import-agent="antigravity"]').check(); + await target.getByRole('button', { name: 'Install 3 copies', exact: true }).waitFor(); + assert(writes.filter(write => write.path.endsWith('/plan')).at(-1).input.providers.includes('antigravity'), 'an explicitly selected undetected tool can receive a skill copy'); + await close(); + await visible(target, '[data-cmd="skills.columns"]').click(); await navigate('instructions'); await target.locator('.tc-doc-preview .tc-code').waitFor(); assert.equal(await target.locator('.tc-reads-row').count(), 3); diff --git a/tests/skill-adapters.test.mjs b/tests/skill-adapters.test.mjs new file mode 100644 index 0000000..3c84eb4 --- /dev/null +++ b/tests/skill-adapters.test.mjs @@ -0,0 +1,77 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import * as fs from "node:fs/promises"; +import path from "node:path"; +import os from "node:os"; +import { adapterProviders, skillAdapter, adapterDetection } from "../docker/setup/skills/adapters.mjs"; +import { skillRoots, createSkillsService } from "../docker/setup/skills.mjs"; + +test("adapter registry covers every provider with portable project locations", () => { + const ids = adapterProviders().map((entry) => entry.id).sort(); + assert.deepEqual(ids, ["antigravity", "claude", "codex", "cursor", "grok", "muse", "opencode"]); + for (const { id, name } of adapterProviders()) { + assert.match(name, /^[A-Za-z ]+$/); + const adapter = skillAdapter(id, { home: "/home/fixture", env: {} }); + assert.equal(adapter.id, id); + assert.equal(adapter.label, name); + assert.ok(path.isAbsolute(adapter.globalDir)); + assert.ok(path.isAbsolute(adapter.detectDir)); + assert.equal(adapter.globalDir, path.join(adapter.detectDir, "skills")); + if (adapter.projectDir !== null) { + assert.match(adapter.projectDir, /^(\.[A-Za-z0-9_-]+)(\/[A-Za-z0-9_.-]+)*$/); + assert.ok(!path.isAbsolute(adapter.projectDir)); + } + } + // Codex, Cursor, OpenCode and Antigravity share one project directory. + for (const id of ["codex", "cursor", "opencode", "antigravity"]) { + assert.equal(skillAdapter(id, { home: "/home/fixture", env: {} }).projectDir, ".agents/skills"); + } + // Grok and Muse project scope stays unverified rather than guessed. + assert.equal(skillAdapter("grok", { home: "/home/fixture", env: {} }).projectDir, null); + assert.equal(skillAdapter("muse", { home: "/home/fixture", env: {} }).projectDir, null); + assert.throws(() => skillAdapter("unknown", { home: "/home/fixture", env: {} }), /Unknown skill provider/); +}); + +test("adapter global locations track the scanned primary roots and overrides", () => { + const home = "/home/fixture"; + for (const { id } of adapterProviders()) { + const adapter = skillAdapter(id, { home, env: {} }); + assert.equal(adapter.globalDir, skillRoots(id, { home, env: {} })[0].path); + } + const env = { CLAUDE_CONFIG_DIR: ".claude-alt", CODEX_HOME: ".codex-alt", GROK_HOME: ".grok-alt", XDG_CONFIG_HOME: ".cfg" }; + assert.equal(skillAdapter("claude", { home, env }).globalDir, path.join(home, ".claude-alt/skills")); + assert.equal(skillAdapter("codex", { home, env }).globalDir, path.join(home, ".codex-alt/skills")); + assert.equal(skillAdapter("grok", { home, env }).globalDir, path.join(home, ".grok-alt/skills")); + assert.equal(skillAdapter("opencode", { home, env }).globalDir, path.join(home, ".cfg/opencode/skills")); + assert.equal(skillAdapter("muse", { home, env }).globalDir, path.join(home, ".cfg/muse/skills")); + assert.equal(skillAdapter("cursor", { home, env }).globalDir, path.join(home, ".cursor/skills")); + assert.equal(skillAdapter("antigravity", { home, env }).globalDir, path.join(home, ".gemini/config/skills")); +}); + +test("detection reflects the install directory without failing", async (t) => { + const home = await fs.mkdtemp(path.join(os.tmpdir(), "t3-adapter-test-")); + t.after(() => fs.rm(home, { recursive: true, force: true })); + const missing = await adapterDetection("claude", { home, env: {}, io: fs }); + assert.equal(missing.detected, false); + assert.equal(missing.detectDir, path.join(home, ".claude")); + await fs.mkdir(path.join(home, ".claude"), { recursive: true }); + const present = await adapterDetection("claude", { home, env: {}, io: fs }); + assert.equal(present.detected, true); + // A broken io still reports undetected instead of throwing. + const broken = await adapterDetection("codex", { home, env: {}, io: { stat: async () => { throw new Error("denied"); } } }); + assert.equal(broken.detected, false); +}); + +test("inventory exposes the adapter without gating management", async (t) => { + const home = await fs.mkdtemp(path.join(os.tmpdir(), "t3-adapter-test-")); + t.after(() => fs.rm(home, { recursive: true, force: true })); + const service = createSkillsService({ home, env: {}, getHarness: async () => null }); + const undetected = await service.list("cursor", { refresh: true }); + assert.equal(undetected.adapter.id, "cursor"); + assert.equal(undetected.adapter.detected, false); + assert.equal(undetected.adapter.globalDir, path.join(home, ".cursor/skills")); + assert.equal(undetected.canCreate, true); + await fs.mkdir(path.join(home, ".cursor"), { recursive: true }); + const detected = await service.list("cursor", { refresh: true }); + assert.equal(detected.adapter.detected, true); +}); diff --git a/tests/skill-sources.test.mjs b/tests/skill-sources.test.mjs index 16234db..4b7ed14 100644 --- a/tests/skill-sources.test.mjs +++ b/tests/skill-sources.test.mjs @@ -82,6 +82,7 @@ test("Git discovery reads bounded blobs without checkout, shell execution, hooks assert.ok(args.includes("cat-file")); return { stdout: Buffer.from(args.at(-1) === "a".repeat(40) ? body : "check") }; }; const result = await f.resolve("https://github.com/org/repo/tree/main/skills/review", { run }); + assert.equal(result.sourceType, "git"); assert.equal(result.skills[0].files.length, 2); assert.equal(result.warnings.length, 2); assert.equal(result.sourceInfo.commit, "e".repeat(40)); assert.equal(result.sourceInfo.ref, "main"); diff --git a/tests/skill-updates.test.mjs b/tests/skill-updates.test.mjs new file mode 100644 index 0000000..5db9cbe --- /dev/null +++ b/tests/skill-updates.test.mjs @@ -0,0 +1,104 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import * as fs from "node:fs/promises"; +import path from "node:path"; +import os from "node:os"; +import { createSkillsService } from "../docker/setup/skills.mjs"; +import { ORIGIN_FILE } from "../docker/setup/skills/origins.mjs"; + +async function fixture(t) { + const home = await fs.mkdtemp(path.join(os.tmpdir(), "t3-skill-updates-")); + t.after(() => fs.rm(home, { recursive: true, force: true })); + let pause = null; + let guide = "Original guide", executable = false, failure = false, removed = false, calls = 0; + const source = "https://github.com/example/skills/tree/main/skills/review"; + const content = "---\nname: review\ndescription: Review changes\n---\nInstructions\n"; + const resolveSource = async (value) => { + assert.equal(value, source); calls++; + if (pause) await pause; + if (failure) throw Error("Repository unavailable"); + return { source, sourceType: "git", warnings: [], skills: removed ? [] : [{ name: "review", description: "Review changes", content, files: [ + { name: "SKILL.md", data: Buffer.from(content), mode: 0o600 }, + { name: "references/guide.md", data: Buffer.from(guide), mode: executable ? 0o700 : 0o600 }, + ] }] }; + }; + const options = { home, env: {}, resolveSource }; + const service = createSkillsService(options); + const install = async (replace = false) => { + const preview = await service.previewSource(source); + const plan = await service.planImport(preview.previewId, ["review"], ["claude"]); + return service.installImport(preview.previewId, plan.planId, replace); + }; + await install(); + const row = async () => (await service.list("claude", { refresh: true })).skills[0]; + return { home, service, options, source, install, row, pause: (promise) => { pause = promise; }, calls: () => calls, + change: () => { guide = "New upstream guide"; }, chmod: () => { executable = true; }, fail: () => { failure = true; }, remove: () => { removed = true; } }; +} + +test("repository origins survive restarts; checks detect supporting-file updates without writing skills", async (t) => { + const f = await fixture(t), row = await f.row(), calls = f.calls(); + assert.equal(row.origin.source, f.source); + const restarted = createSkillsService(f.options); + assert.equal((await restarted.list("claude")).skills[0].origin.source, f.source); + assert.equal(f.calls(), calls, "inventory never contacts the repository"); + assert.equal((await f.service.checkUpdate("claude", row.id)).update.status, "current"); + const local = path.join(path.dirname(row.path), "references/guide.md"); + await fs.writeFile(local, "Local edits"); + assert.equal((await f.service.checkUpdate("claude", row.id)).update.status, "current", "local changes do not masquerade as upstream updates"); + f.change(); + assert.equal((await f.service.checkUpdate("claude", row.id)).update.status, "available"); + assert.equal((await f.row()).update.status, "available"); + assert.equal(await fs.readFile(local, "utf8"), "Local edits"); + await assert.rejects(f.install(), /Confirm replacement/); + assert.equal((await f.install(true)).results[0].status, "installed"); + assert.equal((await f.row()).update, undefined, "the old check does not attach to the new snapshot"); + assert.equal((await f.service.checkUpdate("claude", row.id)).update.status, "current"); + assert.equal(await fs.readFile(local, "utf8"), "New upstream guide"); +}); + +test("file executability changes count as updates; unavailable and removed sources report errors", async (t) => { + const f = await fixture(t), row = await f.row(); + f.chmod(); assert.equal((await f.service.checkUpdate("claude", row.id)).update.status, "available"); + f.remove(); assert.match((await f.service.checkUpdate("claude", row.id)).update.error, /renamed or removed/); + f.fail(); assert.match((await f.service.checkUpdate("claude", row.id)).update.error, /Repository unavailable/); +}); + +test("untracked, malformed and linked provenance cannot trigger repository checks", async (t) => { + const f = await fixture(t), row = await f.row(); + const file = path.join(path.dirname(row.path), ORIGIN_FILE), calls = f.calls(); + await fs.writeFile(file, JSON.stringify({ version: 1, source: "/etc", name: "review", fingerprint: "a".repeat(64) })); + assert.equal((await f.row()).origin, undefined); + await assert.rejects(f.service.checkUpdate("claude", row.id), /no tracked repository/); + await fs.rm(file); await fs.symlink("/etc/passwd", file); + assert.equal((await f.row()).origin, undefined); + assert.equal(f.calls(), calls); +}); + + +test("concurrent checks share a fetch and discard results for a removed installation", { timeout: 3000 }, async (t) => { + const f = await fixture(t), row = await f.row(), before = f.calls(); + let release; + f.pause(new Promise((resolve) => { release = resolve; })); + const first = f.service.checkUpdate("claude", row.id); + const second = f.service.checkUpdate("claude", row.id); + // Both reads share the inventory; wait until the source fetch starts. + while (f.calls() === before) await new Promise((resolve) => setTimeout(resolve, 5)); + await fs.rm(path.join(path.dirname(row.path), ORIGIN_FILE)); + release(); + const results = await Promise.allSettled([first, second]); + assert.equal(f.calls(), before + 1); + assert.ok(results.every((entry) => entry.status === "rejected")); + assert.equal((await f.row()).update, undefined); +}); + + +test("source-supplied tracking metadata cannot impersonate a repository install", async (t) => { + const f = await fixture(t), row = await f.row(); + const local = createSkillsService({ home: f.home, env: {} }); + const preview = await local.previewSource(path.dirname(row.path)); + const plan = await local.planImport(preview.previewId, ["review"], ["cursor"]); + const result = await local.installImport(preview.previewId, plan.planId); + assert.equal(result.results[0].status, "failed"); + assert.match(result.results[0].error, /reserved skill tracking metadata/); + assert.equal((await local.list("cursor")).skills.filter((skill) => skill.source === "Provider").length, 0); +}); diff --git a/tests/skills-api.test.mjs b/tests/skills-api.test.mjs index 0682d94..7bd6946 100644 --- a/tests/skills-api.test.mjs +++ b/tests/skills-api.test.mjs @@ -78,7 +78,7 @@ require('node:readline').createInterface({input:process.stdin}).on('line',line=> assert.equal((await fetch(base + "/context/skills/read?provider=claude&id=" + "a".repeat(32))).status, 401); assert.equal((await get("/context/skills?provider=__proto__")).status, 400); assert.equal((await get("/context/skills")).status, 400); - for (const operation of ["source", "plan", "install", "discard"]) { + for (const operation of ["source", "plan", "install", "discard", "check-update"]) { assert.equal((await fetch(base + "/context/skills/" + operation, { method: "POST" })).status, 401); assert.equal((await post("/context/skills/" + operation, {}, { "sec-fetch-site": "cross-site" })).status, 403); assert.equal((await post("/context/skills/" + operation, null)).status, 400); @@ -101,6 +101,7 @@ require('node:readline').createInterface({input:process.stdin}).on('line',line=> const result = await (await get("/context/skills?provider=claude")).json(); assert.equal(result.coverage, "policy"); assert.equal(result.skills.length, 1); const row = result.skills[0]; assert.equal(row.name, "one"); assert.ok(!("content" in row)); + assert.equal((await post("/context/skills/check-update", { provider: "claude", id: row.id })).status, 403); const readPath = "/context/skills/read?provider=claude&id=" + row.id; assert.equal((await (await get(readPath + "&path=" + encodeURIComponent(other))).json()).content, markdown); assert.deepEqual((await (await get(readPath)).json()).files.map(file => file.name), ["SKILL.md"]); diff --git a/tests/skills-ui.test.mjs b/tests/skills-ui.test.mjs new file mode 100644 index 0000000..bb1a2ef --- /dev/null +++ b/tests/skills-ui.test.mjs @@ -0,0 +1,95 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import vm from 'node:vm'; +import test from 'node:test'; + +const source = await readFile(new URL('../docker/setup/client/skills.js', import.meta.url), 'utf8'); +const model = await readFile(new URL('../docker/setup/client/skill-model.js', import.meta.url), 'utf8'); +const skills = [ + { id: 'user', name: 'My skill', scope: 'user' }, + { id: 'system', name: 'Bundled skill', scope: 'system' }, + { id: 'admin', name: 'Organization skill', scope: 'admin' }, + { id: 'plugin', name: 'Plugin skill', scope: 'user', source: 'Plugin' }, +].map(row => ({ ...row, path: '/skills/' + row.id + '/SKILL.md', resolvedPath: '/skills/' + row.id + '/SKILL.md', description: '', canEdit: row.scope === 'user' })); +async function fixture(rows = skills, storage = new Map(), onRequest) { + const requests = [], installs = []; + const context = vm.createContext({ + Kit: { html: (parts, ...values) => parts.reduce((text, part, i) => text + part + (Array.isArray(values[i]) ? values[i].join('') : values[i] ?? ''), ''), raw: value => value, icon: () => '', announce() {}, menu: (_, config) => config.items.find(item => item.label.startsWith('Review'))?.run() }, + T3Model: { plural: (count, name) => `${count} ${name}${count === 1 ? '' : 's'}`, listOf: names => names.join(', '), absTime: String, relTime: String }, + T3C: { agentMark: () => '' }, + T3SkillImport: { create: () => ({ open: options => installs.push(options) }) }, + T3SkillInspector: { create: () => ({ repaint() {} }) }, + document: { getElementById: () => null }, + localStorage: { getItem: key => storage.get(key), setItem: (key, value) => storage.set(key, value) }, + }); + vm.runInContext(model + '\n' + source + '\nglobalThis.create = T3SkillsUI.create;', context); + const ui = context.create({ render() {}, getStatus: () => ({ harnesses: [{ installed: true, id: 'codex' }, { installed: true, id: 'cursor' }] }), api: async (url, options) => { + requests.push({ url, options }); + const custom = await onRequest?.(url, options); + if (custom) return custom; + const provider = url.includes('cursor') ? 'cursor' : 'codex'; + return { ok: true, data: url.includes('/reach') ? { roots: [] } : { provider, scannedAt: Date.now(), skills: structuredClone(rows) } }; + } }); + await ui.refresh(); + return { ui, requests, storage, installs }; +} + +test('bundled filter hides physical system skills only and persists without provider writes', async () => { + const { ui, requests, storage } = await fixture(); + assert.match(ui.content(), /Bundled skill/); + const before = requests.length; + ui.toggleBundled(); + const content = ui.content(); + assert.doesNotMatch(content, /Bundled skill/); + for (const name of ['My skill', 'Organization skill', 'Plugin skill']) assert.ok(content.includes(name)); + assert.match(content, /1 skill hidden/); + assert.equal(requests.length, before); + const restored = await fixture(skills, storage); + assert.doesNotMatch(restored.ui.content(), /Bundled skill/); + restored.ui.toggleBundled(); + assert.match(restored.ui.content(), /Bundled skill/); +}); + +test('filtered empty state has a working clear action', async () => { + const { ui } = await fixture([skills[1]]); + ui.toggleBundled(); + assert.match(ui.content(), /1 skill hidden/); + assert.match(ui.content(), /No skills match these filters/); + ui.clear(); + assert.match(ui.content(), /Bundled skill/); +}); + +test('blocked browser storage still allows filtering for the session', async () => { + const { ui } = await fixture(skills, { get() { throw Error('blocked'); }, set() { throw Error('blocked'); } }); + ui.toggleBundled(); + assert.doesNotMatch(ui.content(), /Bundled skill/); +}); + +const tracked = { ...skills[0], origin: { source: 'org/repo', name: 'my-skill', fingerprint: 'a'.repeat(64) } }; +test('repository checks deduplicate physical copies and review selects only the owning installation', async () => { + const { ui, requests, installs } = await fixture([tracked], new Map(), url => url.endsWith('/check-update') ? { ok: true, data: { update: { ...tracked.origin, status: 'available' } } } : null); + await ui.checkUpdates(); + const checks = requests.filter(request => request.url.endsWith('/check-update')); + assert.equal(checks.length, 1, 'two agent inventories refer to one physical installation'); + assert.equal(checks[0].options.body.provider, 'codex'); + assert.match(ui.content(), /Update available/); + ui.menu(tracked.resolvedPath, {}); + assert.deepEqual(JSON.parse(JSON.stringify(installs[0])), { source: 'org/repo', names: ['my-skill'], agents: ['codex'], step: 2, mode: 'each' }); +}); + +test('a late update response cannot overwrite a rescanned installation', async () => { + let release; + const gate = new Promise(resolve => { release = resolve; }); + let started; + const begin = new Promise(resolve => { started = resolve; }); + const { ui } = await fixture([tracked], new Map(), async url => { + if (!url.endsWith('/check-update')) return null; + started(); await gate; + return { ok: true, data: { update: { ...tracked.origin, status: 'available' } } }; + }); + const checking = ui.checkUpdates(); + await begin; + await ui.refresh(); + release(); await checking; + assert.doesNotMatch(ui.content(), /Update available/); +});