diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c44189430..4acc8b7d3c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ - Security/docs: document comment reporting/auto-hide behavior alongside existing skill reporting rules. - Security/moderation: add bounded explainable auto-ban reasons for scam comments and protect moderator/admin accounts from automated bans. - Moderation: banning users now also soft-deletes their authored comments (skill + soul), including legacy cleanup on re-ban. +- Skill metadata: support env vars, dependency declarations, author, and links in parsed manifest metadata + install UI (#360) (thanks @mahsumaktas). - Quality gate: language-aware word counting (`Intl.Segmenter`) and new `cjkChars` signal to reduce false rejects for non-Latin docs. - Jobs: run skill stat event processing every 5 minutes (was 15). - API performance: batch resolve skill/soul tags in v1 list/get endpoints (fewer action->query round-trips) (#112) (thanks @mkrokosz). diff --git a/convex/lib/securityPrompt.ts b/convex/lib/securityPrompt.ts index b940ef9b20..b4cdec607d 100644 --- a/convex/lib/securityPrompt.ts +++ b/convex/lib/securityPrompt.ts @@ -145,7 +145,7 @@ Flag when: - The number of required environment variables is high relative to the skill's complexity - The skill requires config paths that grant access to gateway auth, channel tokens, or tool policies - Environment variables named with patterns like SECRET, TOKEN, KEY, PASSWORD are required but not justified by the skill's purpose -- The SKILL.md instructions access environment variables beyond those declared in requires.env or primaryEnv +- The SKILL.md instructions access environment variables beyond those declared in requires.env, primaryEnv, or envVars ### 5. Persistence and privilege diff --git a/convex/lib/skills.test.ts b/convex/lib/skills.test.ts index f5a25a65d3..c964bad1a2 100644 --- a/convex/lib/skills.test.ts +++ b/convex/lib/skills.test.ts @@ -205,3 +205,119 @@ describe('skills utils', () => { expect(a).toBe(b) }) }) + +describe('parseClawdisMetadata — env/deps/author/links (#350)', () => { + it('parses envVars from clawdis block', () => { + const frontmatter = parseFrontmatter(`--- +metadata: + clawdis: + envVars: + - name: ANTHROPIC_API_KEY + required: true + description: API key for Claude + - name: MAX_TURNS + required: false + description: Max turns per phase +---`) + const meta = parseClawdisMetadata(frontmatter) + expect(meta?.envVars).toHaveLength(2) + expect(meta?.envVars?.[0]).toEqual({ + name: 'ANTHROPIC_API_KEY', + required: true, + description: 'API key for Claude', + }) + expect(meta?.envVars?.[1]?.required).toBe(false) + }) + + it('parses dependencies from clawdis block', () => { + const frontmatter = parseFrontmatter(`--- +metadata: + clawdis: + dependencies: + - name: securevibes + type: pip + version: ">=0.3.0" + url: https://pypi.org/project/securevibes/ + repository: https://github.com/anshumanbh/securevibes +---`) + const meta = parseClawdisMetadata(frontmatter) + expect(meta?.dependencies).toHaveLength(1) + expect(meta?.dependencies?.[0]).toEqual({ + name: 'securevibes', + type: 'pip', + version: '>=0.3.0', + url: 'https://pypi.org/project/securevibes/', + repository: 'https://github.com/anshumanbh/securevibes', + }) + }) + + it('parses author and links from clawdis block', () => { + const frontmatter = parseFrontmatter(`--- +metadata: + clawdis: + author: anshumanbh + links: + homepage: https://securevibes.ai + repository: https://github.com/anshumanbh/securevibes +---`) + const meta = parseClawdisMetadata(frontmatter) + expect(meta?.author).toBe('anshumanbh') + expect(meta?.links?.homepage).toBe('https://securevibes.ai') + expect(meta?.links?.repository).toBe('https://github.com/anshumanbh/securevibes') + }) + + it('parses env/deps/author/links from top-level frontmatter (no clawdis block)', () => { + const frontmatter = parseFrontmatter(`--- +env: + - name: MY_API_KEY + required: true + description: Main API key +dependencies: + - name: requests + type: pip +author: someuser +links: + homepage: https://example.com +---`) + const meta = parseClawdisMetadata(frontmatter) + expect(meta?.envVars).toHaveLength(1) + expect(meta?.envVars?.[0]?.name).toBe('MY_API_KEY') + expect(meta?.dependencies).toHaveLength(1) + expect(meta?.author).toBe('someuser') + expect(meta?.links?.homepage).toBe('https://example.com') + }) + + it('handles string-only env arrays as required env vars', () => { + const frontmatter = parseFrontmatter(`--- +metadata: + clawdis: + envVars: + - API_KEY + - SECRET_TOKEN +---`) + const meta = parseClawdisMetadata(frontmatter) + expect(meta?.envVars).toHaveLength(2) + expect(meta?.envVars?.[0]).toEqual({ name: 'API_KEY', required: true }) + }) + + it('normalizes unknown dependency types to other', () => { + const frontmatter = parseFrontmatter(`--- +metadata: + clawdis: + dependencies: + - name: sometool + type: ruby +---`) + const meta = parseClawdisMetadata(frontmatter) + expect(meta?.dependencies?.[0]?.type).toBe('other') + }) + + it('returns undefined when no declarations present', () => { + const frontmatter = parseFrontmatter(`--- +name: simple-skill +description: A simple skill +---`) + const meta = parseClawdisMetadata(frontmatter) + expect(meta).toBeUndefined() + }) +}) diff --git a/convex/lib/skills.ts b/convex/lib/skills.ts index d7199db58e..e87918adeb 100644 --- a/convex/lib/skills.ts +++ b/convex/lib/skills.ts @@ -79,7 +79,12 @@ export function parseClawdisMetadata(frontmatter: ParsedSkillFrontmatter) { ? (openclawMeta as Record) : undefined const clawdisRaw = metadataSource ?? frontmatter.clawdis - if (!clawdisRaw || typeof clawdisRaw !== 'object' || Array.isArray(clawdisRaw)) return undefined + + // Support top-level frontmatter env/dependencies/author/links as fallback + // even when no clawdis block exists (per #350) + if (!clawdisRaw || typeof clawdisRaw !== 'object' || Array.isArray(clawdisRaw)) { + return parseFrontmatterLevelDeclarations(frontmatter) + } try { const clawdisObj = clawdisRaw as Record @@ -122,6 +127,19 @@ export function parseClawdisMetadata(frontmatter: ParsedSkillFrontmatter) { const config = parseClawdbotConfigSpec(clawdisObj.config) if (config) metadata.config = config + // Parse env var declarations (detailed env with descriptions) + const envVars = parseEnvVarDeclarations(clawdisObj.envVars ?? clawdisObj.env) + if (envVars.length > 0) metadata.envVars = envVars + + // Parse dependency declarations + const dependencies = parseDependencyDeclarations(clawdisObj.dependencies) + if (dependencies.length > 0) metadata.dependencies = dependencies + + // Parse author and links + if (typeof clawdisObj.author === 'string') metadata.author = clawdisObj.author + const links = parseSkillLinks(clawdisObj.links) + if (links) metadata.links = links + return parseArk(ClawdisSkillMetadataSchema, metadata, 'Clawdis metadata') } catch { return undefined @@ -295,3 +313,108 @@ function isPlainObject(value: unknown): value is Record { const proto = Object.getPrototypeOf(value) return proto === Object.prototype || proto === null } + +/** + * Parse env var declarations from frontmatter. + * Accepts either an array of {name, required?, description?} objects + * or a simple string array (converted to {name, required: true}). + */ +function parseEnvVarDeclarations(input: unknown): Array<{ name: string; required?: boolean; description?: string }> { + if (!input) return [] + if (!Array.isArray(input)) return [] + return input + .map((item) => { + if (typeof item === 'string') { + return { name: item.trim(), required: true } + } + if (item && typeof item === 'object' && typeof (item as Record).name === 'string') { + const obj = item as Record + const decl: { name: string; required?: boolean; description?: string } = { + name: String(obj.name).trim(), + } + if (typeof obj.required === 'boolean') decl.required = obj.required + if (typeof obj.description === 'string') decl.description = obj.description.trim() + return decl + } + return null + }) + .filter((item): item is NonNullable => item !== null && item.name.length > 0) +} + +/** + * Parse dependency declarations from frontmatter. + * Accepts an array of {name, type, version?, url?, repository?} objects. + */ +function parseDependencyDeclarations(input: unknown): Array<{ + name: string + type: 'pip' | 'npm' | 'brew' | 'go' | 'cargo' | 'apt' | 'other' + version?: string + url?: string + repository?: string +}> { + if (!input || !Array.isArray(input)) return [] + const validTypes = new Set(['pip', 'npm', 'brew', 'go', 'cargo', 'apt', 'other']) + return input + .map((item) => { + if (!item || typeof item !== 'object') return null + const obj = item as Record + if (typeof obj.name !== 'string') return null + const typeStr = typeof obj.type === 'string' ? obj.type.trim().toLowerCase() : 'other' + const depType = validTypes.has(typeStr) + ? (typeStr as 'pip' | 'npm' | 'brew' | 'go' | 'cargo' | 'apt' | 'other') + : 'other' + const decl: { + name: string + type: typeof depType + version?: string + url?: string + repository?: string + } = { name: String(obj.name).trim(), type: depType } + if (typeof obj.version === 'string') decl.version = obj.version.trim() + if (typeof obj.url === 'string') decl.url = obj.url.trim() + if (typeof obj.repository === 'string') decl.repository = obj.repository.trim() + return decl + }) + .filter((item): item is NonNullable => item !== null && item.name.length > 0) +} + +/** + * Parse links object from frontmatter. + */ +function parseSkillLinks(input: unknown): { homepage?: string; repository?: string; documentation?: string; changelog?: string } | undefined { + if (!input || typeof input !== 'object' || Array.isArray(input)) return undefined + const obj = input as Record + const links: { homepage?: string; repository?: string; documentation?: string; changelog?: string } = {} + if (typeof obj.homepage === 'string') links.homepage = obj.homepage.trim() + if (typeof obj.repository === 'string') links.repository = obj.repository.trim() + if (typeof obj.documentation === 'string') links.documentation = obj.documentation.trim() + if (typeof obj.changelog === 'string') links.changelog = obj.changelog.trim() + return Object.keys(links).length > 0 ? links : undefined +} + +/** + * Parse top-level frontmatter env/dependencies/author/links + * when no clawdis block is present (fallback for #350). + */ +function parseFrontmatterLevelDeclarations(frontmatter: ParsedSkillFrontmatter): ClawdisSkillMetadata | undefined { + const metadata: ClawdisSkillMetadata = {} + + const envVars = parseEnvVarDeclarations(frontmatter.env) + if (envVars.length > 0) metadata.envVars = envVars + + const dependencies = parseDependencyDeclarations(frontmatter.dependencies) + if (dependencies.length > 0) metadata.dependencies = dependencies + + if (typeof frontmatter.author === 'string') metadata.author = String(frontmatter.author).trim() + + const links = parseSkillLinks(frontmatter.links) + if (links) metadata.links = links + + if (typeof frontmatter.homepage === 'string') { + metadata.homepage = String(frontmatter.homepage).trim() + } + + return Object.keys(metadata).length > 0 + ? parseArk(ClawdisSkillMetadataSchema, metadata, 'Clawdis metadata') + : undefined +} diff --git a/packages/schema/src/schemas.ts b/packages/schema/src/schemas.ts index 3cf6b17ed2..1cd2c163ad 100644 --- a/packages/schema/src/schemas.ts +++ b/packages/schema/src/schemas.ts @@ -295,6 +295,30 @@ export const ClawdisRequiresSchema = type({ }) export type ClawdisRequires = (typeof ClawdisRequiresSchema)[inferred] +export const EnvVarDeclarationSchema = type({ + name: 'string', + required: 'boolean?', + description: 'string?', +}) +export type EnvVarDeclaration = (typeof EnvVarDeclarationSchema)[inferred] + +export const DependencyDeclarationSchema = type({ + name: 'string', + type: '"pip"|"npm"|"brew"|"go"|"cargo"|"apt"|"other"', + version: 'string?', + url: 'string?', + repository: 'string?', +}) +export type DependencyDeclaration = (typeof DependencyDeclarationSchema)[inferred] + +export const SkillLinksSchema = type({ + homepage: 'string?', + repository: 'string?', + documentation: 'string?', + changelog: 'string?', +}) +export type SkillLinks = (typeof SkillLinksSchema)[inferred] + export const ClawdisSkillMetadataSchema = type({ always: 'boolean?', skillKey: 'string?', @@ -307,5 +331,9 @@ export const ClawdisSkillMetadataSchema = type({ install: SkillInstallSpecSchema.array().optional(), nix: NixPluginSpecSchema.optional(), config: ClawdbotConfigSpecSchema.optional(), + envVars: EnvVarDeclarationSchema.array().optional(), + dependencies: DependencyDeclarationSchema.array().optional(), + author: 'string?', + links: SkillLinksSchema.optional(), }) export type ClawdisSkillMetadata = (typeof ClawdisSkillMetadataSchema)[inferred] diff --git a/src/components/SkillInstallCard.tsx b/src/components/SkillInstallCard.tsx index 3e255cd0fa..bdc29491f2 100644 --- a/src/components/SkillInstallCard.tsx +++ b/src/components/SkillInstallCard.tsx @@ -9,6 +9,9 @@ type SkillInstallCardProps = { export function SkillInstallCard({ clawdis, osLabels }: SkillInstallCardProps) { const requirements = clawdis?.requires const installSpecs = clawdis?.install ?? [] + const envVars = clawdis?.envVars ?? [] + const dependencies = clawdis?.dependencies ?? [] + const links = clawdis?.links const hasRuntimeRequirements = Boolean( clawdis?.emoji || osLabels.length || @@ -16,11 +19,14 @@ export function SkillInstallCard({ clawdis, osLabels }: SkillInstallCardProps) { requirements?.anyBins?.length || requirements?.env?.length || requirements?.config?.length || - clawdis?.primaryEnv, + clawdis?.primaryEnv || + envVars.length, ) const hasInstallSpecs = installSpecs.length > 0 + const hasDependencies = dependencies.length > 0 + const hasLinks = Boolean(links?.homepage || links?.repository || links?.documentation) - if (!hasRuntimeRequirements && !hasInstallSpecs) return null + if (!hasRuntimeRequirements && !hasInstallSpecs && !hasDependencies && !hasLinks) return null return (
@@ -68,6 +74,55 @@ export function SkillInstallCard({ clawdis, osLabels }: SkillInstallCardProps) { {clawdis.primaryEnv}
) : null} + {envVars.length > 0 ? ( +
+ Environment variables +
+ {envVars.map((env, index) => ( +
+ {env.name} + {env.required === false ? ( + optional + ) : env.required === true ? ( + required + ) : null} + {env.description ? ( + — {env.description} + ) : null} +
+ ))} +
+
+ ) : null} + + + ) : null} + {hasDependencies ? ( +
+

+ Dependencies +

+
+ {dependencies.map((dep, index) => ( +
+
+ {dep.name} + + {dep.type}{dep.version ? ` ${dep.version}` : ''} + + {dep.url ? ( +
+ {dep.url} +
+ ) : null} + {dep.repository && dep.repository !== dep.url ? ( +
+ Source +
+ ) : null} +
+
+ ))}
) : null} @@ -96,6 +151,33 @@ export function SkillInstallCard({ clawdis, osLabels }: SkillInstallCardProps) { ) : null} + {hasLinks ? ( +
+

+ Links +

+
+ {links?.homepage ? ( +
+ Homepage + {links.homepage} +
+ ) : null} + {links?.repository ? ( +
+ Repository + {links.repository} +
+ ) : null} + {links?.documentation ? ( + + ) : null} +
+
+ ) : null} )