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` ${mark(id)}${provider.name} ${own ? 'Its own folder' : destination ? 'Reads ' + S.nameOf(destination) + '’s folder for compatibility' : 'Choose this agent to include it'}${duplicate ? '. Finds ' + planner().counts[id] + ' identical copies.' : ''} `;
+ return html` ${mark(id)}${provider.name} ${!getLibrary().installed.includes(id) ? 'Not detected. ' : ''}${own ? 'Its own folder' : destination ? 'Reads ' + S.nameOf(destination) + '’s folder for compatibility' : 'Choose this agent to include it'}${duplicate ? '. Finds ' + planner().counts[id] + ' identical copies.' : ''} `;
};
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.
Fewest copies A copy per agent
${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`
${entry.name}: ${entry.error}
`)}`)}
- ${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`
No writable placement covers ${M.listOf(placement.uncovered.map(S.nameOf))}.
Try a copy per agent to review its folder, or check its folder permissions.
` : ''}
- ${!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`${replacements().length === 1 ? 'Replace ' + replacements()[0].name + ' in ' + S.nameOf(replacements()[0].provider) + '’s folder' : 'Replace ' + replacements().length + ' existing skills'} ${replacements().length > 1 ? html`${replacements().map(entry => entry.name + ' · ' + S.nameOf(entry.provider)).join(', ')} ` : ''}The folder becomes the source’s copy. Local edits and files the source no longer has are removed. Its visibility and permission settings stay. ` : ''}
${view.error && !view.expired && !view.retryInstall ? html`Review destinations again ` : ''}`;
@@ -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
${selectedSkills().map(skill => html`${skill.name}${installedName(skill.name) ? 'Replaces yours' : 'New'} `)} 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.'}
${current.update?.status === 'available' ? 'Review update' : 'Review source'} `;
+ };
const renderFiles = () => html`Files${view.data?.files ? T3Model.plural(view.data.files.length, 'file') : ''}
${view.loading ? html`Reading file details…
` : view.data?.files ? html`${view.data.files.map(file => html`${icon(file.linked ? 'link' : file.executable ? 'terminal' : 'file-text')}${file.name} ${bytes(file.bytes)} `)} ` : 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`${icon('file-text')} ${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)}` : ''}`}
${icon('x')}
${view.error ? html`
${icon('circle-alert')}
${view.data || creating ? 'Couldn’t complete this change' : 'Couldn’t read this skill'} ${view.error}
` : ''}
- ${creating ? html`
` : html`
${skill.description}
${renderAvailability()}${renderLocations()}${renderFiles()}`}
+ ${creating ? html`
` : html`
${skill.description}
${renderAvailability()}${renderOrigin()}${renderLocations()}${renderFiles()}`}
${renderEditor()}
` });
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`${icon('plus')}New skill ${icon('download')}Install from source… `;
- 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.label} · ${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`
${skill.name} ${skill.description} ${readers.map(mark)} ${M.plural(readers.length, 'agent')}${exceptions.length ? ' · ' + exceptions.join(', ') : ''} ${icon('chevron-right')}`;
+ return html`
${skill.name}${updateBadge(skill)} ${skill.description} ${readers.map(mark)} ${M.plural(readers.length, 'agent')}${exceptions.length ? ' · ' + exceptions.join(', ') : ''} ${icon('chevron-right')}`;
})}
`)}
`;
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`
- All ${rows.length} ${S.providers.filter(provider => installed.includes(provider.id)).map(provider => html`${mark(provider.id)}${provider.name}${rows.filter(row => !errors[provider.id] && S.available(row.byProvider[provider.id])).length} `)}
+ All ${rows.length} ${columns().map(provider => html`${mark(provider.id)}${provider.name}${rows.filter(row => !errors[provider.id] && S.available(row.byProvider[provider.id])).length} `)}
+ Hide bundled skills ${hidden ? M.plural(hidden, 'skill') + ' hidden' : 'Filters this list only'} ${showAll ? 'Hide other agents' : 'Show other agents'} ${rows.some(row => row.origin) ? html`${checking ? 'Checking updates…' : 'Check for updates'} ` : ''}
${problems.length ? html`${icon('triangle-alert')}${problems.length === 1 ? problems[0].name + ' may not load' : problems.length + ' skills need attention'} ${problems.length === 1 ? Object.values(problems[0].byProvider).find(row => row.metadataError || row.error)?.metadataError || 'Open the skill to review the problem.' : 'Review skills that may not load.'} ${icon('chevron-right')} ` : ''}
${problems.slice(0, 3).map(attentionNotice)}${problems.length > 3 ? html`and ${problems.length - 3} more ` : ''}${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`Clear filters ` : failed ? html`Retry scan ` : 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`Clear filters ` : failed ? html`Retry scan ` : actions()}
` : html`${table(filtered)}${phoneList(filtered)}`}
${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/);
+});