diff --git a/docker/setup/app.js b/docker/setup/app.js index d0876c7..84fb435 100644 --- a/docker/setup/app.js +++ b/docker/setup/app.js @@ -3404,7 +3404,9 @@ 'storage.preferences': () => storageUI.preferences(), 'storage.versions': () => storageUI.reviewVersions(), 'storage.details': () => storageUI.toggle(), + 'skills.check-updates': () => skillsUI.checkUpdates(), 'skills.refresh': () => skillsUI.refresh(), + 'skills.filter': () => 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 3d50cb1..a6cc840 100644 --- a/docker/setup/client/skill-import.js +++ b/docker/setup/client/skill-import.js @@ -4,7 +4,7 @@ const T3SkillImport = (() => { const { html, raw, icon } = Kit; const create = ({ api, providers, refresh }) => { let active = null; - const open = (provider) => { + const open = (provider, initial = null) => { if (active) return; const view = { source: '', catalogSearch: '', preview: null, plan: null, result: null, skills: new Set(), providers: new Set([provider]), busy: null, error: null, replace: false, layer: null }; active = view; @@ -53,7 +53,7 @@ const T3SkillImport = (() => { const input = view.layer.panel.querySelector('#skill-source'); if (input && input.value !== source) input.value = source; const result = await request('source', { source }, previous); - if (result) { view.preview = result; view.skills = new Set(result.skills.length === 1 ? result.skills.map((skill) => skill.name) : []); } + if (result) { view.preview = result; view.skills = new Set(initial?.name ? result.skills.filter((skill) => skill.name === initial.name).map((skill) => skill.name) : result.skills.length === 1 ? result.skills.map((skill) => skill.name) : []); } render(true); }; view.layer = Kit.open({ kind: 'center', panel: { class: 'tc-sheet tc-context-sheet tc-skill-sheet tc-skill-import ' + (Kit.isPhone() ? 'tc-sheet--bottom' : 'tc-sheet--inset'), role: 'dialog', 'aria-modal': 'true', 'aria-labelledby': 'skill-import-title' }, focus: '#skill-source', backdrop: 'ignore', onEscape: close, @@ -73,7 +73,7 @@ const T3SkillImport = (() => { ${view.plan && !view.result ? html`

${view.source}

${view.plan.destinations.map((entry) => html`
${providers.find(([id]) => id === entry.provider)?.[1]} · ${entry.name}

${entry.action === 'blocked' ? entry.error : entry.action === 'replace' ? 'Replace existing local skill folder and supporting files' : 'Add skill folder'}

${entry.directory ? html`${entry.directory}` : ''}${entry.also?.length ? html`

Same folder also used by ${entry.also.map((id) => providers.find(([key]) => key === id)?.[1]).join(', ')}

` : ''}
`)}
${view.plan.destinations.some((entry) => entry.action === 'replace') ? html`` : ''}

Installs the exact previewed contents. The source is not fetched again. Start a new conversation to load changes; provider policies may still restrict use.

` : ''} - ${view.result ? html`
${view.result.results.map((entry) => html`
${entry.name} · ${providers.find(([id]) => id === entry.provider)?.[1]}

${entry.status === 'failed' ? entry.error : 'Installed'}${entry.warning ? ' · ' + entry.warning : ''}

${entry.directory}
`)}

Start a new conversation to load installed skills. Availability is checked in the provider inventory where supported. To update, preview the source again and review its replacement destinations.

` : ''} + ${view.result ? html`
${view.result.results.map((entry) => html`
${entry.name} · ${providers.find(([id]) => id === entry.provider)?.[1]}

${entry.status === 'failed' ? entry.error : 'Installed'}${entry.warning ? ' · ' + entry.warning : ''}

${entry.directory}
`)}

Start a new conversation to load installed skills. Availability is checked in the provider inventory where supported. Repository installs remember their source. Use Check for updates in the skill list, then review the source before replacing an installation.

` : ''} ${view.busy ? html`

${view.busy === 'install' ? 'Installing…' : view.busy === 'plan' ? 'Checking destinations…' : 'Reading source… You can close this dialog while it finishes.'}

` : ''}
${view.plan && !view.result ? html`` : !view.result ? html`${view.preview?.skills?.length ? html`` : ''}` : ''}
`, }); @@ -113,6 +113,7 @@ const T3SkillImport = (() => { render(true); if (view.result) await refresh(); } }); + if (initial?.source) void preview(initial.source); }; return { open }; }; diff --git a/docker/setup/client/skills.js b/docker/setup/client/skills.js index 9f9bbfe..1553528 100644 --- a/docker/setup/client/skills.js +++ b/docker/setup/client/skills.js @@ -10,7 +10,11 @@ const T3SkillsUI = (() => { : skill.native && skill.state === 'disabled' ? { text: 'Disabled', tone: '' } : { text: 'Unverified', tone: '' }; const create = ({ api, getStatus, render, readMarkdown, downloadMarkdown }) => { - let provider = null, inventory = null, loading = false, error = null, generation = 0, viewer = null, changing = null; + let provider = null, inventory = null, loading = false, error = null, generation = 0, viewer = null, changing = null, checking = false; + const filterKey = 't3-console-hide-bundled-skills'; + let hideBundled = false; + try { hideBundled = localStorage.getItem(filterKey) === 'true'; } catch { /* this session only */ } + const visibleSkills = () => (inventory?.skills || []).filter((skill) => !hideBundled || skill.scope !== 'system'); const importer = T3SkillImport.create({ api, providers, refresh: () => load(true) }); const chooseDefault = () => { const installed = (getStatus()?.harnesses || []).filter((row) => row.installed).map((row) => row.id); @@ -30,7 +34,7 @@ const T3SkillsUI = (() => { }; const providerName = (id) => providers.find(([key]) => key === id)?.[1] || 'this agent'; const coverageSummary = () => !inventory || loading ? '' - : M.plural(inventory.skills?.length || 0, 'skill'); + : M.plural(visibleSkills().length, 'skill') + (hideBundled && inventory.skills?.some((skill) => skill.scope === 'system') ? ' · ' + (inventory.skills.length - visibleSkills().length) + ' hidden' : ''); // What a skill's availability means for this agent, said once under the list. const coverageText = () => !inventory ? 'Refresh after changing skills outside the console.' : inventory.coverage === 'policy' ? 'These settings are ' + providerName(provider) + '’s own, for every project. A project’s settings can still turn a skill off. A change applies to new conversations.' @@ -55,7 +59,7 @@ const T3SkillsUI = (() => { return html`
${control(skill)}
@@ -63,20 +67,37 @@ const T3SkillsUI = (() => { }; const content = () => html`
${coverageSummary()}
${actions()}
+
Hide skills included with the agent from this list.
${loading ? html`

Reading skills…

` : ''} ${error ? html`` : ''} ${!loading && !error && inventory ? html` ${inventory.nativeError ? html`

${icon('triangle-alert')}${providerName(provider)} couldn’t be asked which skills it loads: ${inventory.nativeError}

` : ''} ${(inventory.warnings || []).map((warning) => html`

${icon('triangle-alert')}${warning}

`)} - ${inventory.skills?.length ? html`
${inventory.skills.map(row)}
` : ''} - ${!inventory.skills?.length ? html`
${inventory.warnings?.length ? 'No skills could be shown' : 'No skills yet'}${inventory.warnings?.length ? 'The messages above say why.' : 'Nothing in ' + providerName(provider) + '’s global skill folders. Add one here, or install one from a source.'}
` : ''} + ${visibleSkills().length ? html`
${visibleSkills().map(row)}
` : ''} + ${!visibleSkills().length ? html`
${inventory.skills?.length ? 'All skills are hidden' : inventory.warnings?.length ? 'No skills could be shown' : 'No skills yet'}${inventory.skills?.length ? 'Turn off Hide bundled skills to show them.' : inventory.warnings?.length ? 'The messages above say why.' : 'Nothing in ' + providerName(provider) + '’s global skill folders. Add one here, or install one from a source.'}
` : ''} ${inventory.roots?.length ? html`
Looked in ${M.plural(inventory.roots.length, 'folder')}${inventory.roots.map((root) => html`${root.path}`)}
` : ''} + ${inventory.adapter && !inventory.adapter.detected ? html`

${icon('info', 'tc-icon--sm')}${providerName(provider)} isn’t detected here, so these are plain files until it is installed. Adding, editing and importing still work.

` : ''} ` : ''}
${icon('info', 'tc-icon--sm')}${coverageText()}
`; - const actions = () => html`${inventory?.canCreate ? html`` : ''}`; + const actions = () => html`${inventory?.skills?.some((skill) => skill.origin) ? html`` : ''}${inventory?.canCreate ? html`` : ''}`; + const checkUpdates = async () => { + if (loading || changing || checking) return; + const selected = provider, current = generation, rows = (inventory?.skills || []).filter((skill) => skill.origin); + checking = true; render(); + try { + for (const skill of rows) { + if (provider !== selected || generation !== current) break; + const response = await api('/context/skills/check-update', { body: { provider: selected, id: skill.id } }); + if (provider !== selected || generation !== current) break; + if (response.ok) skill.update = response.data.update; + else skill.update = { status: 'error', error: response.error }; + render(); + } + } finally { checking = false; render(); } + }; const setState = async (id, state) => { const row = inventory?.skills?.find((skill) => skill.id === id); if ((!row?.canToggle && !row?.canSetState) || loading || changing) return; @@ -114,6 +135,7 @@ const T3SkillsUI = (() => {
${creating ? html`${inventory?.creationTargets?.length > 1 ? html`

Shared skills can be used by multiple providers. Editing or removing them affects every provider using the same files.

` : ''}

Use lowercase letters, numbers and hyphens. The frontmatter name must match.

` : html`
Location${current.data?.path || row.path}${(current.data?.resolvedPath || row.resolvedPath) && (current.data?.resolvedPath || row.resolvedPath) !== row.path ? html`Linked target${current.data?.resolvedPath || row.resolvedPath}` : ''}

${stateOf(row).text}${row.invocation === 'manual' ? ' · Manual invocation requested by the skill' : row.invocation === 'model-only' ? ' · User invocation disabled in frontmatter' : ''}. ${row.stateAuthority === 'user-policy' ? 'Native user policy checked by the managed provider. ' + row.effectiveStateText + '. Session rules can differ.' : row.native ? 'Listed by Codex in the managed setup environment.' : 'File found on disk; provider availability is unverified.'}

`} + ${!creating && row.origin ? html`
Installed from${row.origin.source}

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

` : ''} ${!creating && row.stateReadOnlyReason ? html`

${row.stateReadOnlyReason}

` : ''} ${row.metadataError ? html`

${row.metadataError}

` : ''} ${current.error ? html`` : ''} @@ -123,7 +145,7 @@ const T3SkillsUI = (() => { ${current.editing ? html`
${editorMeta()}${creating ? 'Creates the skill in the selected location.' : 'Saving updates this file wherever it is used. Supporting files and symlinks stay in place.'} Ctrl/⌘ S to save.
` : (current.data?.readOnlyReason || current.data?.removalError) ? html`

${current.data.readOnlyReason || current.data.removalError}

` : ''} ` : ''}
- ${current.editing ? html`` : html`${current.data?.canRemove ? html`` : ''}${current.data?.canEdit ? html`` : ''}`}
`, + ${current.editing ? html`` : html`${current.data?.canRemove ? html`` : ''}${row.origin && current.data?.canEdit ? html`` : ''}${current.data?.canEdit ? html`` : ''}`}
`, }); const syncEditor = () => { const meta = current.layer.panel.querySelector('#skill-editor-meta'); @@ -160,6 +182,7 @@ const T3SkillsUI = (() => { else if (button.hasAttribute('data-skill-copy')) Kit.copy(current.draft, button, 'Skill Markdown copied'); else if (button.hasAttribute('data-skill-download')) download(); else if (button.hasAttribute('data-skill-upload')) current.layer.panel.querySelector('#skill-file').click(); + else if (button.hasAttribute('data-skill-update')) { current.layer.close('update'); importer.open(current.provider, { source: row.origin.source, name: row.origin.name }); } else if (button.hasAttribute('data-skill-edit')) { current.editing = true; current.layer.render(); current.layer.panel.querySelector('#skill-content').focus(); } else if (button.hasAttribute('data-skill-save') || button.hasAttribute('data-skill-remove')) { const removing = button.hasAttribute('data-skill-remove'); @@ -189,9 +212,14 @@ const T3SkillsUI = (() => { else current.error = response.error; current.layer.render(); }; - return { content, actions, setState, install: () => importer.open(provider || chooseDefault()), view: (id, editing = false) => open(id, editing), add: () => { if (inventory?.canCreate) return open(null, true, true); }, + return { content, actions, checkUpdates, setState, install: () => importer.open(provider || chooseDefault()), view: (id, editing = false) => open(id, editing), add: () => { if (inventory?.canCreate) return open(null, true, true); }, activate: () => { if (!inventory && !loading && !error) load(); }, refresh: () => load(true), + toggleBundled: () => { + hideBundled = !hideBundled; + try { localStorage.setItem(filterKey, String(hideBundled)); } catch { /* this session only */ } + render(); + }, select: (id) => { if (!providers.some(([key]) => key === id) || id === provider) return; viewer?.layer.close('provider'); diff --git a/docker/setup/console.css b/docker/setup/console.css index 489c4fa..6c3d985 100644 --- a/docker/setup/console.css +++ b/docker/setup/console.css @@ -644,6 +644,10 @@ html.tc-locked { overflow: hidden; } .tc-skill-toolbar { display: flex; align-items: center; flex-wrap: wrap; gap: var(--space-3); padding: var(--space-4); } .tc-skill-toolbar .tc-label { margin: 0; } .tc-skill-toolbar select { width: auto; min-width: 140px; max-width: 100%; } +.tc-skill-filter { display: flex; align-items: center; flex-wrap: wrap; gap: var(--space-3); padding: 0 var(--space-4) var(--space-4); font-size: 12px; } +.tc-skill-filter label { cursor: pointer; } +.tc-skill-filter .tc-switch { flex: none; } +.tc-skill-filter .tc-hint { margin: 0; } .tc-skill-count { white-space: nowrap; } .tc-skill-actions { display: flex; align-items: center; flex-wrap: wrap; gap: var(--space-2); } @media (max-width: 639.98px) { diff --git a/docker/setup/server.mjs b/docker/setup/server.mjs index 9a63091..7197364 100644 --- a/docker/setup/server.mjs +++ b/docker/setup/server.mjs @@ -2153,7 +2153,7 @@ const ROUTES = ["/login", "/logout", "/hello", "/status", "/pair", "/revoke", "/ "/providers", "/updates/check", "/packages/install", "/packages/update", "/packages/uninstall", "/packages/cancel", "/packages/registry", "/packages/versions", "/packages/info", - "/context/agents-md", "/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"]; + "/context/agents-md", "/context/skills/read", "/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", "/context/skills"]; // One background pass for the opt-in auto-updater. Checks are cheap and // always safe; installs run only when enabled, an update is known, and @@ -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 3f282af..04e0071 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"; const PROVIDERS = Object.freeze({ claude: "Claude Code", codex: "Codex", opencode: "OpenCode", cursor: "Cursor", grok: "Grok Build", muse: "Muse Code", antigravity: "Antigravity" }); @@ -145,7 +148,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 }); @@ -194,6 +197,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." @@ -204,7 +216,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.", @@ -268,5 +280,30 @@ 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 }); - return { list, read, setState, create: fileManager.create, save: fileManager.save, remove: fileManager.remove, ...importer }; + 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); } + }; + return { list, read, 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 83ee3f3..a154d2d 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 }); @@ -68,7 +69,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."); @@ -80,12 +81,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); @@ -135,7 +141,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 40781d1..1c08ae4 100644 --- a/docker/setup/skills/sources.mjs +++ b/docker/setup/skills/sources.mjs @@ -255,7 +255,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); @@ -269,6 +269,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, skills, warnings: [...new Set(warnings)].slice(0, 30) }; + return { source: value, 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 cbd6835..0a73812 100644 --- a/docs/context-management.md +++ b/docs/context-management.md @@ -102,9 +102,20 @@ and runtime metadata, as described below. Cursor, Grok and Muse still show **Unverified** availability. 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. + Inventory loads when the Skills tab opens or its agent changes. **Refresh skills** rescans after external changes. 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. Bodies load separately through authenticated, discovered-installation IDs. Details refuse a link retargeted after discovery. No skill files, provider settings or persistence mappings change during inventory reads. Native Codex @@ -222,10 +233,23 @@ 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 selected. 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..0468bb0 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..0dea403 Binary files /dev/null and b/docs/media/skills/adapter-note-phone-light.png differ diff --git a/scripts/skills-ui-audit.js b/scripts/skills-ui-audit.js index fc7b1c2..3310179 100644 --- a/scripts/skills-ui-audit.js +++ b/scripts/skills-ui-audit.js @@ -59,6 +59,7 @@ const checkSelect = async (page, select, theme) => { let gateRead = false, releaseRead, oldReadStarted, readError = false, inventoryError = false; let saveError = true, gateSave = false, releaseSave, saveStarted; let stateError = true, gateState = false, releaseState, stateStarted, firstEnabled = true; + let bundledFixture = false, trackedFixture = false, updateStatus = 'available'; let policies = false, importFailure = true, gateImport = false, releaseImport, importStarted; let gateSource = false, releaseSource, sourceStarted; let gatePlan = false, releasePlan, planStarted, importPlanning = false; @@ -67,7 +68,8 @@ const checkSelect = async (page, select, theme) => { const importFocus = async (id) => assert.equal(await page.evaluate(() => document.activeElement.id), id); const policyValues = { claude: 'on', opencode: 'allow' }; const mutations = []; - const skill = (provider, id, name) => ({ id, name, description: 'A description that is long enough to wrap comfortably on a phone. '.repeat(4), + const skill = (provider, id, name) => ({ id, name, + ...(trackedFixture && id === firstId ? { origin: { source: 'org/repo@review', name: 'review', fingerprint: 'a'.repeat(64) } } : {}), description: 'A description that is long enough to wrap comfortably on a phone. '.repeat(4), path: '/fixture/a-long-global-configuration-directory/skills/' + name + '/SKILL.md', resolvedPath: '/fixture/shared-source/skills/' + name + '/SKILL.md', source: 'Shared .agents', native: provider === 'codex', state: policies && provider !== 'codex' ? policyValues[provider] : id === firstId && firstEnabled ? 'enabled' : 'disabled', bytes: body.length, @@ -77,13 +79,16 @@ const checkSelect = async (page, select, theme) => { canEdit: true, canRemove: true, revision: 'fixture-revision', removalRevision: 'fixture-removal', removalKind: 'directory', removalPath: '/fixture/local/review' }); const inventoryFor = (provider) => ({ ok: true, provider, canCreate: true, creationTargets: [{ id: 'provider', name: 'Provider folder' }, ...(provider === 'codex' || provider === 'opencode' ? [{ id: 'shared', name: 'Shared .agents folder' }] : [])], - skills: [skill(provider, firstId, 'review'), skill(provider, secondId, 'manual-review')], + skills: [skill(provider, firstId, 'review'), skill(provider, secondId, 'manual-review'), ...(bundledFixture ? [{ ...skill(provider, 'c'.repeat(32), 'bundled-example'), scope: 'system', source: 'System', canToggle: false, canSetState: false }] : [])], coverage: provider === 'codex' ? 'native' : policies ? 'policy' : 'files', warnings: [], coverageText: provider + ' global skill inventory' }); await page.route('**/context/skills**', async (route) => { const url = new URL(route.request().url()), provider = url.searchParams.get('provider'); if (route.request().method() === 'POST') { const input = route.request().postDataJSON(); mutations.push({ path: url.pathname, input }); - if (url.pathname.endsWith('/source')) { + if (url.pathname.endsWith('/check-update')) { + assert.equal(input.id, firstId); + await route.fulfill({ json: { ok: true, id: input.id, update: { status: updateStatus, error: updateStatus === 'error' ? 'Repository unavailable' : undefined } } }); + } else if (url.pathname.endsWith('/source')) { if (gateSource) { gateSource = false; sourceStarted(); await new Promise((resolve) => { releaseSource = resolve; }); } if (input.source === 'bad-source') await route.fulfill({ status: 400, json: { ok: false, error: 'No skill found at this source.' } }); else if (input.source === 'catalog') await route.fulfill({ json: { ok: true, skills: [], warnings: [], links: [{ label: 'Frontend skill', source: 'org/repo@review' }, { label: 'Other skill', source: 'org/other' }] } }); @@ -168,6 +173,25 @@ const checkSelect = async (page, select, theme) => { assert.equal(await page.locator('#skill-provider').inputValue(), 'opencode'); await page.locator('#skill-provider').selectOption('codex'); await page.locator('.tc-skills[data-provider="codex"]').waitFor(); + // Hiding bundled skills changes only the list, never the provider's settings. + bundledFixture = true; + await page.getByRole('button', { name: 'Refresh skills', exact: true }).click(); + await page.getByRole('button', { name: 'View bundled-example', exact: true }).waitFor(); + await capture('bundled-visible'); + const beforeFilterMutations = mutations.length; + await page.getByRole('switch', { name: 'Hide bundled skills', exact: true }).click(); + assert.equal(await page.locator('.tc-skill-row').count(), 2); + assert.equal(await page.locator('.tc-skill-count').innerText(), '2 skills · 1 hidden'); + assert.equal(await page.getByRole('button', { name: 'View bundled-example', exact: true }).count(), 0); + assert.equal(mutations.length, beforeFilterMutations); + assert.equal(await page.evaluate(() => document.documentElement.scrollWidth > innerWidth), false); + await capture('bundled-hidden'); + await page.getByRole('switch', { name: 'Hide bundled skills', exact: true }).click(); + assert.equal(await page.locator('.tc-skill-row').count(), 3); + bundledFixture = false; + await page.getByRole('button', { name: 'Refresh skills', exact: true }).click(); + await page.getByRole('button', { name: 'View review', exact: true }).waitFor(); + assert.equal(await page.getByRole('button', { name: 'View bundled-example', exact: true }).count(), 0); // Codex's own on/off for each skill is a switch in its row. const codexStates = () => page.locator('.tc-skill-row [role="switch"]').evaluateAll((all) => all.map((el) => el.getAttribute('aria-checked'))); const reviewSwitch = page.getByRole('switch', { name: 'review for Codex', exact: true }); @@ -381,6 +405,26 @@ const checkSelect = async (page, select, theme) => { await capture('results'); await page.getByRole('button', { name: 'Done', exact: true }).click(); assert.equal(await page.evaluate(() => document.documentElement.scrollWidth > innerWidth), false); + trackedFixture = true; readError = false; + await page.getByRole('button', { name: 'Refresh skills', exact: true }).click(); + const checkUpdates = page.getByRole('button', { name: 'Check for updates', exact: true }); + await checkUpdates.waitFor(); + await checkUpdates.click(); + await page.getByText('Update available', { exact: true }).waitFor(); + await capture('update-available'); + await page.getByRole('button', { name: 'View review', exact: true }).click(); + await page.locator('[data-skill-update]').waitFor(); + await page.locator('[data-skill-update]').click(); + await page.locator('[data-import-skill="review"]').waitFor(); + assert.equal(await page.locator('#skill-source').inputValue(), 'org/repo@review'); + assert.equal(await page.locator('[data-import-skill="review"]').isChecked(), true); + await capture('review-update'); + await page.getByRole('dialog').getByRole('button', { name: 'Close', exact: true }).last().click(); + updateStatus = 'error'; await checkUpdates.click(); + await page.getByText('Update check failed', { exact: true }).waitFor(); + updateStatus = 'current'; await checkUpdates.click(); + await page.getByText('Up to date', { exact: true }).waitFor(); + assert.equal(await page.evaluate(() => document.documentElement.scrollWidth > innerWidth), false); assert.deepEqual(errors, []); console.log('PASS Skill inventory, native state, files and source installation:', theme, width + 'x' + height, embedded ? 'embedded-query fallback' : 'standalone'); await page.close(); 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 b9144c6..f0da585 100644 --- a/tests/skill-sources.test.mjs +++ b/tests/skill-sources.test.mjs @@ -81,6 +81,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(calls.filter((args) => args.includes("cat-file")).length, 2); await assert.rejects(fs.stat(path.dirname(temporary)), { code: "ENOENT" }); 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 26a1352..39943f0 100644 --- a/tests/skills-api.test.mjs +++ b/tests/skills-api.test.mjs @@ -72,7 +72,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); @@ -95,6 +95,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.equal((await get("/context/skills/read?provider=claude&id=" + encodeURIComponent(other))).status, 400); diff --git a/tests/skills-ui.test.mjs b/tests/skills-ui.test.mjs new file mode 100644 index 0000000..a475ede --- /dev/null +++ b/tests/skills-ui.test.mjs @@ -0,0 +1,60 @@ +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 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' }, +]; +async function fixture(rows = skills, storage = new Map()) { + const requests = []; + 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: () => '' }, + T3Model: { plural: (count, name) => `${count} ${name}${count === 1 ? '' : 's'}` }, + T3SkillImport: { create: () => ({}) }, + localStorage: { getItem: (key) => storage.get(key), setItem: (key, value) => storage.set(key, value) }, + }); + vm.runInContext(source + '\nglobalThis.create = T3SkillsUI.create;', context); + const ui = context.create({ render() {}, getStatus: () => ({ harnesses: [{ installed: true, id: 'codex' }] }), api: async (url) => { + requests.push(url); + return { ok: true, data: { provider: 'codex', skills: rows } }; + } }); + await ui.refresh(); + return { ui, requests, storage }; +} + +test('bundled filter hides only system skills and never changes provider state', async () => { + const { ui, requests, storage } = await fixture(); + assert.match(ui.content(), /View Bundled skill/); + ui.toggleBundled(); + const content = ui.content(); + assert.doesNotMatch(content, /View Bundled skill/); + for (const name of ['My skill', 'Organization skill', 'Plugin skill']) assert.ok(content.includes('View ' + name)); + assert.match(content, /3 skills · 1 hidden/); + assert.equal(requests.length, 1, 'filtering needs no API call'); + const restored = await fixture(skills, storage); + assert.doesNotMatch(restored.ui.content(), /View Bundled skill/); + restored.ui.toggleBundled(); + assert.match(restored.ui.content(), /View Bundled skill/); + assert.match(restored.ui.content(), /4 skills/); +}); + +test('filtered empty state explains how to reveal bundled skills', async () => { + const { ui } = await fixture([skills[1]]); + ui.toggleBundled(); + assert.match(ui.content(), /0 skills · 1 hidden/); + assert.match(ui.content(), /All skills are hidden/); + assert.match(ui.content(), /Turn off Hide bundled skills to show them/); + assert.doesNotMatch(ui.content(), /No skills yet/); +}); + +test('blocked browser storage still allows filtering for the session', async () => { + const storage = { get() { throw Error('blocked'); }, set() { throw Error('blocked'); } }; + const { ui } = await fixture(skills, storage); + ui.toggleBundled(); + assert.doesNotMatch(ui.content(), /View Bundled skill/); +});