Files
zpan/scripts/lint-zpan-skill.mjs
T
agent-kanban[bot] e50c19051a docs: publish ZPan Agent Skill (#543)
* docs: publish zpan agent skill

Agent-Profile: https://agent-kanban.dev/agents/b0abe6cd7aeba133

* test: cover openapi auth route metadata

Agent-Profile: https://agent-kanban.dev/agents/b0abe6cd7aeba133

* fix: align zpan skill restish commands

Agent-Profile: https://agent-kanban.dev/agents/b0abe6cd7aeba133

* docs: clarify zpan skill release boundaries

* docs: align zpan upload profile examples

Agent-Profile: https://agent-kanban.dev/agents/b0abe6cd7aeba133

* fix: select upload plugin profile via environment

* fix: return zpan upload plugin help

Agent-Profile: https://agent-kanban.dev/agents/b0abe6cd7aeba133

---------

Co-authored-by: Noah Reed <noah-reed@mails.agent-kanban.dev>
Co-authored-by: saltbo <saltbo@foxmail.com>
2026-07-29 20:19:16 -04:00

204 lines
9.1 KiB
JavaScript

#!/usr/bin/env node
import { readdirSync, readFileSync, statSync } from 'node:fs'
import { join, relative } from 'node:path'
const ROOT = process.cwd()
const SKILL_DIR = join(ROOT, 'skills', 'zpan')
function walkMarkdown(dir) {
const files = []
for (const name of readdirSync(dir)) {
const full = join(dir, name)
const stat = statSync(full)
if (stat.isDirectory()) files.push(...walkMarkdown(full))
else if (name.endsWith('.md')) files.push(full)
}
return files.sort()
}
const files = walkMarkdown(SKILL_DIR)
const documents = files.map((file) => ({
file,
rel: relative(ROOT, file),
text: readFileSync(file, 'utf8'),
}))
const corpus = documents.map((doc) => doc.text).join('\n\n')
const normalizedCorpus = corpus.toLowerCase()
const failures = []
function requireMatch(label, pattern) {
if (!pattern.test(corpus)) failures.push(`missing required contract: ${label}`)
}
function requireText(label, text) {
if (!normalizedCorpus.includes(text.toLowerCase())) {
failures.push(`missing required contract: ${label}`)
}
}
function forbidMatch(label, pattern) {
for (const doc of documents) {
for (const match of doc.text.matchAll(pattern)) {
const line = doc.text.slice(0, match.index).split('\n').length
failures.push(`forbidden contract text: ${label} (${doc.rel}:${line})`)
}
}
}
function forbidUnsafeLine(label, pattern) {
const safePrefix = /\b(do not|don't|never|must not|not|no)\b/i
for (const doc of documents) {
const lines = doc.text.split('\n')
lines.forEach((lineText, index) => {
if (pattern.test(lineText) && !safePrefix.test(lineText)) {
failures.push(`unsafe contract guidance: ${label} (${doc.rel}:${index + 1})`)
}
})
}
}
function commandLines() {
return corpus
.split('\n')
.map((line) => line.trim())
.filter((line) => line.startsWith('restish ') || /^RSH_PROFILE=\S+\s+restish\b/.test(line))
}
function requireCommandLine(label, pattern) {
if (!commandLines().some((line) => pattern.test(line))) {
failures.push(`missing executable command example: ${label}`)
}
}
function forbidCommandLine(label, pattern) {
for (const doc of documents) {
const lines = doc.text.split('\n')
lines.forEach((lineText, index) => {
const line = lineText.trim()
if (/^(?:RSH_PROFILE=\S+\s+)?restish /.test(line) && pattern.test(line)) {
failures.push(`forbidden executable command: ${label} (${doc.rel}:${index + 1})`)
}
})
}
}
function validateSkillFrontmatter() {
const skill = documents.find((doc) => doc.rel === 'skills/zpan/SKILL.md')
if (!skill) {
failures.push('missing skills/zpan/SKILL.md')
return
}
const match = skill.text.match(/^---\n([\s\S]*?)\n---\n/)
if (!match) {
failures.push('missing Skill YAML frontmatter')
return
}
const keys = [...match[1].matchAll(/^([A-Za-z0-9_-]+):/gm)].map((entry) => entry[1])
const extras = keys.filter((key) => key !== 'name' && key !== 'description')
if (extras.length > 0) {
failures.push(`unsupported Skill frontmatter key(s): ${extras.join(', ')}`)
}
}
validateSkillFrontmatter()
requireMatch('Restish v2.3 or later', /Restish v2\.3(?:\+| or later)/i)
requireText('connect exactly /api/openapi.json', '/api/openapi.json')
requireText('plugin install command', 'restish plugin install saltbo/zpan zpan')
requireText('upload command surface', 'restish zpan-upload')
for (const command of [
'list-objects',
'get-object',
'create-object',
'update-object',
'copy-object',
'transfer-object',
'delete-object',
'list-shares',
'create-share',
'revoke-share',
'get-user-quota',
'get-storage-usage',
'list-download-tasks',
'get-download-task',
'list-download-task-events',
]) {
requireCommandLine(`restish zpan ${command}`, new RegExp(`\\brestish\\s+(?:--rsh-profile\\s+\\S+\\s+)?zpan\\s+${command}\\b`))
}
for (const operationId of ['createObject', 'presignObjectUploadParts', 'completeObjectUpload', 'abortObjectUpload']) {
requireText(`upload plugin validates ${operationId}`, operationId)
}
requireCommandLine('list pagination uses --page-size', /\bzpan\s+list-objects\b.*\s--page-size\s+\d+/)
requireCommandLine('share pagination uses --page-size', /\bzpan\s+list-shares\b.*\s--page-size\s+\d+/)
requireCommandLine('task pagination uses --page-size', /\bzpan\s+list-download-tasks\b.*\s--page-size\s+\d+/)
requireCommandLine('create-object uses positional body input', /\bzpan\s+create-object\s+'[^']*\bname:/)
requireCommandLine('update-object uses positional body input', /\bzpan\s+update-object\s+\S+\s+'[^']*\bname:/)
requireCommandLine('copy-object uses positional body input', /\bzpan\s+copy-object\s+\S+\s+'[^']*\bparent:/)
requireCommandLine('transfer-object uses positional body input', /\bzpan\s+transfer-object\s+\S+\s+'[^']*\btargetOrgId:/)
requireCommandLine('create-share uses positional body input', /\bzpan\s+create-share\s+'[^']*\bmatterId:/)
requireCommandLine('revoke-share uses positional body input', /\bzpan\s+revoke-share\s+\S+\s+'[^']*\bstatus:\s*revoked/)
requireCommandLine('upload passes Restish and plugin profiles', /\bRSH_PROFILE=(\S+)\s+restish\s+zpan-upload\b.*\s--api\s+zpan\b.*\s--profile\s+\1\b/)
requireText('reader profile', '`reader`')
requireText('file-manager profile', '`file-manager`')
requireText('publisher profile', '`publisher`')
requireText('ci profile', '`ci`')
requireMatch('least-privilege profile selection', /(least-privilege|narrowest) profile/i)
requireText('objects read scope', 'objects:read')
requireText('objects write scopes', 'objects:create')
requireText('share publishing scopes', 'shares:create')
requireText('environment-backed Agent API key', 'Environment-backed')
requireMatch('OAuth authorization code with PKCE', /OAuth authorization code \+ PKCE|authorization code\s*\+\s*PKCE/i)
requireMatch('CI Agent API key guidance', /CI[\s\S]{0,240}Agent API key|Agent API key[\s\S]{0,240}CI/i)
requireMatch('confirm target workspace', /confirm[\s\S]{0,120}workspace/i)
requireMatch('confirm conflict policy', /confirm[\s\S]{0,160}(conflict|overwrite|replace)/i)
requireMatch('confirm destructive delete', /confirm[\s\S]{0,160}(destructive|soft delete|delet)/i)
requireMatch('confirm permanent purge', /confirm[\s\S]{0,160}(purge|permanent)/i)
requireMatch('confirm public sharing', /confirm[\s\S]{0,160}public share/i)
requireMatch(
'confirm plugin executable trust',
/(?:confirm|ask)[\s\S]{0,200}(trusted local executable|executable Restish plugin|plugin trust)/i,
)
forbidMatch('agent OpenAPI document', /\/api\/openapi\.agent\.json/gi)
forbidMatch('standalone zpan file CLI', /standalone\s+`?zpan`?\s+file CLI/gi)
const openApiDocs = [...corpus.matchAll(/\/api\/openapi(?:\.[a-z0-9-]+)?\.json/gi)].map((match) => match[0])
for (const doc of openApiDocs) {
if (doc !== '/api/openapi.json') {
failures.push(`OpenAPI document must be exactly /api/openapi.json, found ${doc}`)
}
}
forbidUnsafeLine('bearer-token paste flow', /\b(paste|copy\/paste|copy paste)\b.*\bbearer token\b/i)
forbidUnsafeLine('Agent device login as v2.9 flow', /\b(device authorization|device login|device flow)\b.*\bv2\.9\b/i)
forbidUnsafeLine('Skill-handled multipart orchestration', /\b(Skill|agent)\b.*\b(orchestrate|handle|implement)\b.*\bmultipart\b/i)
forbidUnsafeLine('Skill-handled ETag retry loop', /\b(Skill|agent)\b.*\b(ETag|ETags)\b.*\b(retry|retries|loop|loops)\b/i)
forbidUnsafeLine('presigned URL exposure', /\b(expose|return|print|show)\b.*\bpresigned URLs?\b/i)
forbidMatch('silent plugin install approval', /restish\s+plugin\s+install\s+saltbo\/zpan\s+zpan[^\n]*--yes/gi)
forbidMatch('old Restish list limit flag', /\brestish\s+(?:--rsh-profile\s+\S+\s+)?zpan\s+(?:list-objects|list-shares|list-download-tasks)\b[^\n]*\s--limit\b/gi)
forbidMatch('camelCase Restish command example', /\brestish\s+(?:--rsh-profile\s+\S+\s+)?zpan\s+(?:listObjects|getObject|createObject|updateObject|copyObject|transferObject|deleteObject|purgeTrashObject|listShares|createShare|revokeShare|getUserQuota|getStorageUsage|listDownloadTasks|getDownloadTask|listDownloadTaskEvents)\b/gi)
forbidMatch('profile template purge command', /\brestish\s+--rsh-profile\s+(?:reader|file-manager|publisher|ci)\s+zpan\s+purge-trash-object\b/gi)
forbidMatch('invented operator profile', /\brestish\s+--rsh-profile\s+operator\b/gi)
forbidCommandLine('upload without plugin profile', /\b(?:RSH_PROFILE=\S+\s+)?restish\s+(?:--rsh-profile\s+\S+\s+)?zpan-upload\b(?!.*\s--profile\s+\S+)/i)
forbidMatch('upload with ineffective host profile flag', /\brestish\s+--rsh-profile\s+\S+\s+zpan-upload\b/gi)
forbidCommandLine('upload without delegated profile environment', /^restish\s+zpan-upload\b/i)
forbidMatch(
'MCP upload control-plane allowlist',
/restish\s+mcp\s+serve[\s\S]*?--operations[^\n]*(createObject|create-object|presignObjectUploadParts|presign-object-upload-parts|completeObjectUpload|complete-object-upload|abortObjectUpload|abort-object-upload)/gi,
)
if (failures.length > 0) {
console.error(`ZPan Skill static contract failed with ${failures.length} finding(s):`)
for (const failure of failures) console.error(`- ${failure}`)
process.exit(1)
}
console.log(`ZPan Skill static contract passed (${documents.length} markdown files checked)`)