From 533a51e9e316b67924659c77bdd4948cdbfb810a Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Tue, 6 Oct 2026 01:53:47 +1100 Subject: [PATCH 1/3] chore(analytics): remove the unused CLI event endpoint The skilld CLI never sent telemetry, and skilld-protocol dropped the CliEventInputSchema contract in skilld-dev/skilld#148. The endpoint was the last import of that schema, so the site could not take a newer skilld-protocol while it stayed. --- server/api/events/cli.post.ts | 25 -------------------- server/schemas/cli-event.ts | 7 ------ shared/analytics.ts | 34 --------------------------- test/unit/anonymous-analytics.test.ts | 19 +-------------- 4 files changed, 1 insertion(+), 84 deletions(-) delete mode 100644 server/api/events/cli.post.ts delete mode 100644 server/schemas/cli-event.ts diff --git a/server/api/events/cli.post.ts b/server/api/events/cli.post.ts deleted file mode 100644 index 6596e039..00000000 --- a/server/api/events/cli.post.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { CliEventInput } from '~~/server/schemas/cli-event' -import { analyticsCountry, cliDataPoint } from '#shared/analytics' -import { defineApiHandler } from '#shared/server/handler' - -export default defineApiHandler({ - schema: CliEventInput, - handler: async ({ event, body, platform }) => { - const dataset = platform.env.SKILLD_ANALYTICS - if (!dataset || typeof dataset.writeDataPoint !== 'function') - throw createError({ statusCode: 500, message: 'Analytics Engine binding missing' }) - - dataset.writeDataPoint(cliDataPoint({ - event: body.event, - surface: body.surface, - sourceKind: body.sourceKind ?? '', - slug: body.slug ?? '', - cliVersion: body.cliVersion, - agent: body.agent ?? '', - country: analyticsCountry(getHeader(event, 'cf-ipcountry')), - durationMs: body.durationMs ?? 0, - })) - - return { ok: true as const } - }, -}) diff --git a/server/schemas/cli-event.ts b/server/schemas/cli-event.ts deleted file mode 100644 index 5a07de10..00000000 --- a/server/schemas/cli-event.ts +++ /dev/null @@ -1,7 +0,0 @@ -/** - * Re-export of the canonical CLI event schema from `skilld-protocol`. The - * schema is aliased to keep existing handlers (`schema: CliEventInput`) - * working without renames. - */ - -export { CliEventInputSchema as CliEventInput } from 'skilld-protocol/wire' diff --git a/shared/analytics.ts b/shared/analytics.ts index 347c5d31..11316178 100644 --- a/shared/analytics.ts +++ b/shared/analytics.ts @@ -30,17 +30,6 @@ export interface CopyEvent { country: string } -export interface CliEvent { - event: string - surface: string - sourceKind: string - slug: string - cliVersion: string - agent: string - country: string - durationMs: number -} - export interface AnalyticsDataPoint { blobs: string[] doubles: number[] @@ -74,26 +63,3 @@ export function copyDataPoint(event: CopyEvent): AnalyticsDataPoint { indexes: [analyticsIndex(event.slug)], } } - -/** - * Blobs: event, surface, sourceKind, slug, cliVersion, agent, country. - * - * The CLI may send a `userId`, because the same endpoint serves signed-in - * runs. It is dropped here. The index is the Skill, never the account, so an - * account cannot be followed across runs. - */ -export function cliDataPoint(event: CliEvent): AnalyticsDataPoint { - return { - blobs: [ - event.event, - event.surface, - event.sourceKind, - event.slug, - event.cliVersion, - event.agent, - event.country, - ], - doubles: [1, event.durationMs], - indexes: [analyticsIndex(event.slug || event.event)], - } -} diff --git a/test/unit/anonymous-analytics.test.ts b/test/unit/anonymous-analytics.test.ts index 3266cbc1..3e39bd2c 100644 --- a/test/unit/anonymous-analytics.test.ts +++ b/test/unit/anonymous-analytics.test.ts @@ -1,6 +1,6 @@ import type { EventHandler } from 'h3' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { analyticsCountry, analyticsIndex, cliDataPoint, copyDataPoint } from '../../shared/analytics' +import { analyticsCountry, analyticsIndex, copyDataPoint } from '../../shared/analytics' describe('anonymous analytics data points', () => { it('writes a copy as surface, mode, kind, slug, and country', () => { @@ -17,23 +17,6 @@ describe('anonymous analytics data points', () => { }) }) - it('keeps no account id on a CLI run from a signed-in person', () => { - const point = cliDataPoint({ - event: 'run', - surface: 'cli:run', - sourceKind: 'gh', - slug: 'obra/superpowers', - cliVersion: '3.1.0', - agent: 'claude-code', - country: 'US', - durationMs: 420, - }) - - expect(point.blobs).toEqual(['run', 'cli:run', 'gh', 'obra/superpowers', '3.1.0', 'claude-code', 'US']) - expect(point.doubles).toEqual([1, 420]) - expect(point.indexes).toEqual(['obra/superpowers']) - }) - it('cuts an index that would breach the 96-byte Analytics Engine limit', () => { const long = `${'a'.repeat(120)}/skill` expect(analyticsIndex(long)).toHaveLength(32) From 250784136285c32c77a3429eb2cf01b53f8bb39a Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Tue, 6 Oct 2026 01:53:47 +1100 Subject: [PATCH 2/3] feat(registry): show Skill behaviors on Skill pages A reader asking whether a Skill is fine to run had only the declared allowed-tools, which most Skills leave out. The Skill page now runs the skilld CLI's own behavior rules from skilld-protocol over SKILL.md and the stored file names, lists each behavior with links to the exact lines, and marks the ones skilld run holds for approval. The page reads no supporting file content, so the panel says which inputs it checked and that skilld run checks every file. --- GLOSSARY.md | 9 ++ .../registry/app/components/SkillDetail.vue | 36 ++++-- .../app/components/_SkillBehaviors.vue | 103 ++++++++++++++++++ layers/registry/app/utils/skill-behaviors.ts | 20 ++++ .../server/api/skills/[...slug].get.ts | 7 ++ .../registry/server/utils/skill-behaviors.ts | 41 +++++++ test/unit/skill-detail-behaviors.test.ts | 61 +++++++++++ test/unit/skill-page-behaviors.test.ts | 37 +++++++ 8 files changed, 303 insertions(+), 11 deletions(-) create mode 100644 layers/registry/app/components/_SkillBehaviors.vue create mode 100644 layers/registry/app/utils/skill-behaviors.ts create mode 100644 layers/registry/server/utils/skill-behaviors.ts create mode 100644 test/unit/skill-detail-behaviors.test.ts create mode 100644 test/unit/skill-page-behaviors.test.ts diff --git a/GLOSSARY.md b/GLOSSARY.md index 72c59a26..0397331a 100644 --- a/GLOSSARY.md +++ b/GLOSSARY.md @@ -27,6 +27,7 @@ Shared terms match the skilld CLI glossary (`skilld-dev/skilld/GLOSSARY.md`). Th | registry | skilld.dev | one | "skilld" | | skilld token | `cli_tokens`, `layers/identity` | Author 1—N token | "skilld token" | | provenance | Skill detail, cards | Skill 1—1 author and source link | "written by", "source" | +| behavior | `skilld-protocol/behaviors`, Skill page | Skill 1—N behavior | "Skill behaviors" | Collisions @@ -173,6 +174,14 @@ Existing route paths, table names, and protocol fields keep their identifiers. **Never:** trust score, verification tier, "curated by" for an author. +### behavior + +**Is:** one thing a Skill's files ask an Agent to do, found by a fixed text pattern from `skilld-protocol/behaviors`. The skilld CLI applies the same rules. + +**Use for:** the "Skill behaviors" panel and rail. A behavior marked "Needs approval" stops `skilld run` until the user approves it. The Skill page reads only SKILL.md and file names. + +**Never:** permission, capability, risk, threat, scan result, "safe", "secure". No match never means the Skill does nothing. + ### Owner **Is:** the GitHub organization or user that hosts Skill Repositories. A proxied entity, never a row skilld.dev owns. Lives at `/gh/[owner]`. diff --git a/layers/registry/app/components/SkillDetail.vue b/layers/registry/app/components/SkillDetail.vue index b48947d1..de541e3b 100644 --- a/layers/registry/app/components/SkillDetail.vue +++ b/layers/registry/app/components/SkillDetail.vue @@ -1,11 +1,13 @@ + + diff --git a/layers/registry/app/utils/skill-behaviors.ts b/layers/registry/app/utils/skill-behaviors.ts new file mode 100644 index 00000000..5618d740 --- /dev/null +++ b/layers/registry/app/utils/skill-behaviors.ts @@ -0,0 +1,20 @@ +const BEHAVIOR_ICONS: Record = { + shell: 'i-lucide-terminal', + scripts: 'i-lucide-file-code', + packages: 'i-lucide-package', + network: 'i-lucide-globe', + env: 'i-lucide-variable', + mcp: 'i-lucide-plug', +} + +/** + * The icon for one Skill behavior. + * + * Every behavior that needs approval shares one warning shape, so the tier + * never rests on colour alone. + */ +export function behaviorIcon(behavior: { id: string, tier: 'ask' | 'show' }): string { + if (behavior.tier === 'ask') + return 'i-lucide-triangle-alert' + return BEHAVIOR_ICONS[behavior.id] ?? 'i-lucide-circle-dot' +} diff --git a/layers/registry/server/api/skills/[...slug].get.ts b/layers/registry/server/api/skills/[...slug].get.ts index 0f973f74..81fe98f7 100644 --- a/layers/registry/server/api/skills/[...slug].get.ts +++ b/layers/registry/server/api/skills/[...slug].get.ts @@ -13,6 +13,7 @@ import { TAG_BY_SLUG } from '../../jobs/taxonomy' import { SkillDetailResponseSchema } from '../../schemas/skill-responses' import { getTree, GITHUB_PAGE_READ_TIMEOUT_MS, resolveGithubBindings } from '../../utils/github-client' import { resolveRepoSourceIdentityFromRow } from '../../utils/repo-source-identity' +import { skillPageBehaviors } from '../../utils/skill-behaviors' import { getGeneratedKinds } from '../../utils/skill-generated' import { skillImagePolicyForEvent } from '../../utils/skill-image-policy' import { parseSkillMd } from '../../utils/skill-md-render' @@ -327,6 +328,11 @@ async function loadSkillDetail(event: H3Event, platform: Platform, slug: string) } const allowedTools = parseAllowedTools(rendered.frontmatter) const capability = classifyAllowedTools(allowedTools) + const behaviors = skillPageBehaviors({ + raw: rendered.raw, + assetPaths: assets.map(asset => asset.path), + source: { owner: source.owner, repo: source.repo, branch, skillPath: rendered.skillPath }, + }) const selectedAssets = selectSkillFiles(assets) // `rendered.*` describes the cached copy, which survives the file being // deleted upstream, so it can only ever say "we can still render this". The @@ -415,6 +421,7 @@ async function loadSkillDetail(event: H3Event, platform: Platform, slug: string) capabilityScopes: capability.scopes, mcpServers: capability.mcpServers, }, + behaviors, }, tags, keywords, diff --git a/layers/registry/server/utils/skill-behaviors.ts b/layers/registry/server/utils/skill-behaviors.ts new file mode 100644 index 00000000..2da3a14e --- /dev/null +++ b/layers/registry/server/utils/skill-behaviors.ts @@ -0,0 +1,41 @@ +import type { Behavior, BehaviorLocation } from 'skilld-protocol/behaviors' +import { detectBehaviors } from 'skilld-protocol/behaviors' +import { githubSkillFileUrl } from '#shared/skill-file-url' + +export type SkillPageBehavior = Omit & { + locations: Array +} + +/** + * The Skill behaviors a Skill page can show. + * + * The page holds SKILL.md and the file names the sync stored, never supporting + * file content, so only those inputs reach the shared rules. `skilld run` + * applies the same rules to every file. + */ +export function skillPageBehaviors(input: { + raw: string | null + assetPaths: readonly string[] + source: { owner: string, repo: string, branch: string | null, skillPath: string | null } +}): SkillPageBehavior[] { + const files = [ + ...(input.raw === null ? [] : [{ path: 'SKILL.md', text: input.raw }]), + ...input.assetPaths.map(path => ({ path })), + ] + const directory = input.source.skillPath?.replace(/(?:^|\/)SKILL\.md$/, '') ?? null + const url = (location: BehaviorLocation): string | null => { + if (directory === null) + return null + const file = githubSkillFileUrl({ + owner: input.source.owner, + repo: input.source.repo, + skillPath: directory ? `${directory}/${location.path}` : location.path, + branch: input.source.branch, + }) + return file && location.line !== null ? `${file}#L${location.line}` : file + } + return detectBehaviors(files).map(behavior => ({ + ...behavior, + locations: behavior.locations.map(location => ({ ...location, url: url(location) })), + })) +} diff --git a/test/unit/skill-detail-behaviors.test.ts b/test/unit/skill-detail-behaviors.test.ts new file mode 100644 index 00000000..a49bb264 --- /dev/null +++ b/test/unit/skill-detail-behaviors.test.ts @@ -0,0 +1,61 @@ +import type { H3Event } from 'h3' +import type { SqliteD1 } from './helpers/d1-sqlite' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { allMigrations, createSqliteD1 } from './helpers/d1-sqlite' + +const NOW_SEC = Math.floor(Date.now() / 1000) + +vi.stubGlobal('defineEventHandler', (handler: unknown) => handler) +vi.stubGlobal('defineCachedEventHandler', (handler: unknown) => handler) +vi.stubGlobal('createError', (input: Record) => Object.assign(new Error(String(input.message)), input)) +vi.stubGlobal('getUserSession', () => Promise.resolve(null)) +vi.stubGlobal('getRouterParam', (_event: unknown, key: string) => (key === 'slug' ? 'acme/skills/setup' : undefined)) +const cacheMap = new Map() +vi.stubGlobal('useStorage', () => ({ + getItem: async (key: string) => cacheMap.get(key) ?? null, + setItem: async (key: string, value: unknown) => { + cacheMap.set(key, value) + }, +})) + +let harness: SqliteD1 + +beforeEach(() => { + vi.resetModules() + cacheMap.clear() + harness = createSqliteD1(allMigrations()) + harness.raw.prepare(`INSERT INTO repos (owner, repo, default_branch) VALUES ('acme', 'skills', 'main')`).run() + const raw = '---\nname: setup\ndescription: Set up the project.\n---\n\n```sh\nsudo apt-get install jq\n```\n' + harness.raw.prepare( + `INSERT INTO skills (owner, repo, name, slug, display_name, source_resolved, + rendered_skill_path, rendered_status, rendered_raw, rendered_html, rendered_at, assets) + VALUES ('acme', 'skills', 'setup', 'acme/skills/setup', 'setup', 1, + 'skills/setup/SKILL.md', 'ok', ?, '

ok

', ?, ?)`, + ).run(raw, NOW_SEC, JSON.stringify([{ path: 'scripts/install.sh', size: 12, type: 'code' }])) +}) + +describe('skill detail behaviors', () => { + it('names each behavior in SKILL.md and the stored file names, with a link to where it appears', async () => { + const handler = (await import('../../layers/registry/server/api/skills/[...slug].get')).default as (event: H3Event) => Promise<{ + sourceFacts: { behaviors: Array<{ id: string, tier: string, locations: Array<{ path: string, line: number | null, url: string | null }> }> } + }> + + const body = await handler({ + context: { platform: { db: harness.db } }, + node: { req: { headers: {} } }, + } as unknown as H3Event) + + expect(body.sourceFacts.behaviors.map(behavior => [behavior.id, behavior.tier])).toEqual([ + ['privilege', 'ask'], + ['shell', 'show'], + ['scripts', 'show'], + ['packages', 'show'], + ]) + expect(body.sourceFacts.behaviors[0]?.locations).toEqual([{ + path: 'SKILL.md', + line: 7, + url: expect.stringMatching(/^https:\/\/github\.com\/acme\/skills\/blob\/[^/]+\/skills\/setup\/SKILL\.md#L7$/), + }]) + expect(body.sourceFacts.behaviors[2]?.locations[0]?.url).toMatch(/\/skills\/setup\/scripts\/install\.sh$/) + }) +}) diff --git a/test/unit/skill-page-behaviors.test.ts b/test/unit/skill-page-behaviors.test.ts new file mode 100644 index 00000000..73a82089 --- /dev/null +++ b/test/unit/skill-page-behaviors.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest' +import { skillPageBehaviors } from '../../layers/registry/server/utils/skill-behaviors' + +const source = { owner: 'acme', repo: 'skills', branch: 'main', skillPath: 'skills/setup/SKILL.md' } + +describe('skillPageBehaviors', () => { + it('links each SKILL.md match to its line and each file name match to its file', () => { + const behaviors = skillPageBehaviors({ + raw: '---\nname: setup\ndescription: Set up.\n---\n\n```sh\nsudo apt-get install jq\n```\n', + assetPaths: ['scripts/install.sh', 'references/api.md'], + source, + }) + + expect(behaviors.map(behavior => [behavior.id, behavior.tier, behavior.locations])).toEqual([ + ['privilege', 'ask', [{ path: 'SKILL.md', line: 7, url: 'https://github.com/acme/skills/blob/main/skills/setup/SKILL.md#L7' }]], + ['shell', 'show', [{ path: 'SKILL.md', line: 6, url: 'https://github.com/acme/skills/blob/main/skills/setup/SKILL.md#L6' }]], + ['scripts', 'show', [{ path: 'scripts/install.sh', line: null, url: 'https://github.com/acme/skills/blob/main/skills/setup/scripts/install.sh' }]], + ['packages', 'show', [{ path: 'SKILL.md', line: 7, url: 'https://github.com/acme/skills/blob/main/skills/setup/SKILL.md#L7' }]], + ]) + }) + + it('links a Skill at the Repository root without a directory prefix', () => { + const [scripts] = skillPageBehaviors({ + raw: null, + assetPaths: ['run.py'], + source: { ...source, skillPath: 'SKILL.md' }, + }) + + expect(scripts?.locations[0]?.url).toBe('https://github.com/acme/skills/blob/main/run.py') + }) + + it('leaves locations unlinked when the SKILL.md path is unknown', () => { + const [scripts] = skillPageBehaviors({ raw: null, assetPaths: ['run.py'], source: { ...source, skillPath: null } }) + + expect(scripts?.locations[0]?.url).toBeNull() + }) +}) From 2a8406146dda15d3eb07d21180735c319ac15d4c Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Tue, 6 Oct 2026 02:23:50 +1100 Subject: [PATCH 3/3] chore(deps): bump skilld-protocol to 2.5.0 skilld-protocol 2.5.0 exports skilld-protocol/behaviors, which the Skill page uses. Only its lockfile entries change; a full re-resolve on main also moves wrangler, which belongs in its own update. --- pnpm-lock.yaml | 14 +++++++------- pnpm-workspace.yaml | 4 ++-- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fd51685e..fc1ddff5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -339,8 +339,8 @@ catalogs: specifier: ^2.2.0 version: 2.2.0 skilld-protocol: - specifier: ^2.4.0 - version: 2.4.0 + specifier: ^2.5.0 + version: 2.5.0 skilld-sdk: specifier: 0.1.2 version: 0.1.2 @@ -497,7 +497,7 @@ importers: version: 2.2.0 skilld-protocol: specifier: 'catalog:' - version: 2.4.0 + version: 2.5.0 skilld-sdk: specifier: 'catalog:' version: 0.1.2 @@ -8447,9 +8447,9 @@ packages: resolution: {integrity: sha512-XAuW9x2cI3B757A/h2sFYfye5kBUHM8Gr6Muzwtve1oYRCv3BntoKMcgNu+GNF/LKIvUD1fQpBTVUCoHlzMihg==} engines: {node: '>=18.0.0'} - skilld-protocol@2.4.0: - resolution: {integrity: sha512-a0wOcj3Wx1E0tO48flN2zMeaBDKB8GSNyv/TjeOArBYWB4Lzgmt3fyti/qIb11zvJmbpGNaS+Hy4Lh7D1I+2jg==} - engines: {node: '>=18'} + skilld-protocol@2.5.0: + resolution: {integrity: sha512-l17WT+Y2nXt/X0rMNOAWTjcDBgUG76a929k9DpJKJQMFhxqNSeA4HFTdUsBm9cpNp9gYO5FX6n7jHF10dSvDQw==} + engines: {node: '>=22.0.0'} skilld-sdk@0.1.2: resolution: {integrity: sha512-nFo5TfDcMn8dRAM700L0nF3vwm5r/EENHBRNcNty2+OMXATNQTKU6VWOCkb3xIP2BGRtvbCI9maoV4vhgQKMYw==} @@ -18738,7 +18738,7 @@ snapshots: dependencies: fast-xml-parser: 5.10.1 - skilld-protocol@2.4.0: + skilld-protocol@2.5.0: dependencies: zod: 4.5.4 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 08f8a0d0..542604d7 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -28,7 +28,7 @@ minimumReleaseAgeExclude: - sitemapd@0.2.1 || 0.2.2 - nuxt-skew-protection@1.5.2 - '@nuxtjs/seo@5.3.14' - - skilld-protocol@2.4.0 + - skilld-protocol@2.5.0 - skilld-sdk@0.1.2 - skilld - skilld-cli-darwin-arm64 @@ -237,7 +237,7 @@ catalog: nuxt-og-image: ^6.9.1 nuxt-skew-protection: ^1.5.4 rangi: ^2.2.0 - skilld-protocol: ^2.4.0 + skilld-protocol: ^2.5.0 skilld-sdk: 0.1.2 tailwindcss: ^4.3.3 tsx: ^4.23.13