Skip to content

Commit 757f16d

Browse files
aRustyDevclaude
andcommitted
fix(catalog): classify download failures and guard against corrupt sources
- Classify npx skills failures from stderr: auth failures (private/deleted repos), 404 not found, access denied, timeouts — captured in errorDetail - Validate source is org/repo format before attempting download — rejects corrupt entries with filesystem paths from prior runs - Error log now contains actionable failure reasons instead of generic "download failed" Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 0d71df2 commit 757f16d

1 file changed

Lines changed: 38 additions & 4 deletions

File tree

.scripts/commands/skill.ts

Lines changed: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -711,6 +711,18 @@ export default defineCommand({
711711

712712
for (const entry of batch) {
713713
const ref = `${entry.source}@${entry.skill}`
714+
715+
// Guard: validate source is org/repo format
716+
if (!entry.source.match(/^[\w.-]+\/[\w./-]+$/) || entry.source.startsWith('/')) {
717+
results.set(entry.skill, {
718+
path: null,
719+
error: `invalid source format: ${entry.source.slice(0, 100)}`,
720+
errorType: 'download_failed',
721+
errorDetail: 'source is not in org/repo format — corrupt catalog entry',
722+
})
723+
continue
724+
}
725+
714726
try {
715727
await execAsync(`npx -y skills add -y --copy --full-depth ${ref}`, {
716728
cwd: wtPath,
@@ -760,14 +772,36 @@ export default defineCommand({
760772
err.message.includes('timed out') ||
761773
err.message.includes('killed'))
762774
const stderr = (err as { stderr?: string })?.stderr ?? ''
775+
const msg = err instanceof Error ? err.message : String(err)
763776
const exitCode = (err as { code?: number })?.code
777+
const combined = `${stderr} ${msg}`.toLowerCase()
778+
779+
// Classify the failure reason from stderr/message
780+
const isAuthFailure =
781+
combined.includes('authentication failed') ||
782+
combined.includes('could not read username')
783+
const isNotFound =
784+
combined.includes('not found') ||
785+
combined.includes('404') ||
786+
combined.includes('does not exist')
787+
const isPrivate =
788+
combined.includes('private') ||
789+
combined.includes('permission denied') ||
790+
combined.includes('403')
791+
792+
let reason = 'unknown'
793+
if (isTimeout) reason = 'timeout (30s)'
794+
else if (isAuthFailure) reason = 'auth failed (repo may be private or deleted)'
795+
else if (isNotFound) reason = 'repo not found (404)'
796+
else if (isPrivate) reason = 'access denied (private repo)'
797+
else if (stderr) reason = stderr.slice(0, 200)
798+
else reason = msg.slice(0, 200)
799+
764800
results.set(entry.skill, {
765801
path: null,
766-
error: isTimeout
767-
? 'download timed out (30s)'
768-
: `download failed: ${stderr.slice(0, 300) || (err instanceof Error ? err.message.slice(0, 300) : 'unknown')}`,
802+
error: `download failed: ${reason}`,
769803
errorType: isTimeout ? 'download_timeout' : 'download_failed',
770-
errorDetail: stderr.slice(0, 500) || undefined,
804+
errorDetail: stderr.slice(0, 500) || msg.slice(0, 500),
771805
errorCode: exitCode,
772806
})
773807
}

0 commit comments

Comments
 (0)