diff --git a/extensions/skills/CHANGELOG.md b/extensions/skills/CHANGELOG.md index 562015345e1..a95ee7f343a 100644 --- a/extensions/skills/CHANGELOG.md +++ b/extensions/skills/CHANGELOG.md @@ -1,12 +1,22 @@ # Skills Changelog +## [Improve Installed Indicators, Source Matching & Refresh] - {PR_MERGE_DATE} + +- Replace the green "Installed" tag in "Search Skills" with a green check-circle indicator and green skill icon for installed skills +- Show the skill source (`owner/repo`) as the subtitle in "Search Skills" and "Manage Skills" to better distinguish skills between them +- Move "Search Skills" result details out of the side panel and into a full-screen detail view +- Detect skills with the same `skillId` installed from different sources. Show a warning indicator and rename the "Install Skill" action to "Replace Installed Skill" for clarity on the outcome +- Immediately refresh installed indicators in "Search Skills" after installing or replacing a skill +- Add "Refresh Installed Skills" actions with Cmd+R in "Manage Skills" +- Consolidate shared install, remove, and update action handling for more consistent confirmations, toasts, and error messages + ## [Fix Windows bunx Fallback] - 2026-04-30 - Fix the Skills CLI throwing `'"bunx"' is not recognized as an internal or external command` on Windows when Bun is not installed; the extension now correctly falls back to `npx` as intended ## [Add bunx support] - 2026-04-28 -- Added initial support for `bunx`, it is only called optimally if `npx` is not found +- Added initial support for `npx`, it is only called optimally if `bunx` is not found ## [Add Skills CLI Telemetry Opt-Out] - 2026-04-28 diff --git a/extensions/skills/README.md b/extensions/skills/README.md index faeae297e50..699e146cf69 100644 --- a/extensions/skills/README.md +++ b/extensions/skills/README.md @@ -5,7 +5,8 @@ Search and manage AI agent skills from [Skills](https://skills.sh) directly in R ## Features - Search for specific skills -- See which search results are already installed with a green "Installed" badge +- See which search results are already installed with a green check-circle indicator +- Identify skills installed from a different source with a conflict warning - Filter available skills by owner - Install skills for all supported agents - View security audit status from `skills.sh` before installing @@ -14,8 +15,8 @@ Search and manage AI agent skills from [Skills](https://skills.sh) directly in R - Filter installed skills by agent - View skill source, install date, and update date from the lock file - Open installed skill repositories on GitHub -- View skill details inline with SKILL.md content, including description, license, compatibility, and allowed tools (toggle with Cmd+D) -- See GitHub star counts in the detail panel +- Open search result details in a full-screen view with SKILL.md content, including description, license, compatibility, and allowed tools +- See GitHub star counts in the detail view - Copy install commands - Quick access to GitHub repositories @@ -23,7 +24,7 @@ Search and manage AI agent skills from [Skills](https://skills.sh) directly in R ### Search Skills -Search for agent skills from skills.sh with real-time results. View skill details in the inline panel, including security audit status when available. +Search for agent skills from skills.sh with real-time results. Results show which skills are installed locally and flag skills that may conflict with a local install from another source. Open a result to view full-screen details, including security audit status when available. ### Manage Skills @@ -31,6 +32,7 @@ View, update, and remove installed skills. Outdated skills are highlighted with ## Screenshots -![Search Skills](metadata/skills-1.png) -![Search Skills - Owner Filter](metadata/skills-2.png) -![Manage Skills](metadata/skills-3.png) +![Search Skills - Owner Filter](metadata/skills-1.png) +![Search Skills - Details](metadata/skills-2.png) +![Search Skills - Installed Status](metadata/skills-3.png) +![Manage Skills](metadata/skills-4.png) diff --git a/extensions/skills/metadata/skills-1.png b/extensions/skills/metadata/skills-1.png index 1daeeae4cbc..e0171e53f85 100644 Binary files a/extensions/skills/metadata/skills-1.png and b/extensions/skills/metadata/skills-1.png differ diff --git a/extensions/skills/metadata/skills-2.png b/extensions/skills/metadata/skills-2.png index 8fee5c17ca4..345d11f3ee4 100644 Binary files a/extensions/skills/metadata/skills-2.png and b/extensions/skills/metadata/skills-2.png differ diff --git a/extensions/skills/metadata/skills-3.png b/extensions/skills/metadata/skills-3.png index 882f25f7c3e..32f851bc3ca 100644 Binary files a/extensions/skills/metadata/skills-3.png and b/extensions/skills/metadata/skills-3.png differ diff --git a/extensions/skills/metadata/skills-4.png b/extensions/skills/metadata/skills-4.png new file mode 100644 index 00000000000..560efed8e5f Binary files /dev/null and b/extensions/skills/metadata/skills-4.png differ diff --git a/extensions/skills/src/components/InstalledSkillListItem.tsx b/extensions/skills/src/components/InstalledSkillListItem.tsx index 01b164ec2bb..1fd9bd1ff2d 100644 --- a/extensions/skills/src/components/InstalledSkillListItem.tsx +++ b/extensions/skills/src/components/InstalledSkillListItem.tsx @@ -82,6 +82,7 @@ interface InstalledSkillListItemProps { isShowingDetail: boolean; mutate: MutateSkills; onToggleDetail: () => void; + onRefresh: () => void; } export function InstalledSkillListItem({ @@ -90,6 +91,7 @@ export function InstalledSkillListItem({ isShowingDetail, mutate, onToggleDetail, + onRefresh, }: InstalledSkillListItemProps) { const extraAgents = skill.agentCount - skill.agents.length; const agentsText = extraAgents > 0 ? `${skill.agents.join(", ")} +${extraAgents} more` : skill.agents.join(", "); @@ -97,7 +99,7 @@ export function InstalledSkillListItem({ return ( + } /> diff --git a/extensions/skills/src/components/SkillDetailView.tsx b/extensions/skills/src/components/SkillDetailView.tsx new file mode 100644 index 00000000000..6b6bd5e19d6 --- /dev/null +++ b/extensions/skills/src/components/SkillDetailView.tsx @@ -0,0 +1,206 @@ +import { Action, ActionPanel, Color, Detail, Icon, Keyboard } from "@raycast/api"; +import { useCallback, useEffect, useRef } from "react"; +import { + type AuditStatus, + AUDIT_PROVIDER_LABELS, + buildInstallCommand, + formatInstalls, + normalizeAllowedTools, + type Skill, + SKILLS_BASE_URL, +} from "../shared"; +import { useRepoStats } from "../hooks/useRepoStats"; +import { useSkillAudits } from "../hooks/useSkillAudits"; +import { useSkillContent } from "../hooks/useSkillContent"; +import { useInstalledSkillMatches } from "../hooks/useInstalledSkillMatches"; +import { type SkillAuditsAvailabilityState } from "../utils/skill-audits"; +import { showSkillAuditErrorToast } from "../utils/skill-audit-error-toast"; +import { InstallSkillAction } from "./actions/InstallSkillAction"; +import { OpenSecurityAuditActions } from "./actions/OpenSecurityAuditActions"; + +const AUDIT_STATUS_META: Record = { + pass: { emoji: "✅", label: "Pass" }, + warn: { emoji: "⚠️", label: "Warn" }, + fail: { emoji: "🛑", label: "Fail" }, + unknown: { emoji: "", label: "Unknown" }, +}; + +function formatAuditStatus(status: AuditStatus): string { + const { emoji, label } = AUDIT_STATUS_META[status]; + return emoji ? `${emoji} ${label}` : label; +} + +function getAuditFallbackText(isLoading: boolean, availabilityState?: SkillAuditsAvailabilityState): string { + if (isLoading) return "Loading..."; + if (availabilityState === "parse-error" || availabilityState === "fetch-error") return "Unable to verify"; + return "Pending"; +} + +interface SkillDetailViewProps { + skill: Skill; + onSkillInstalled?: () => void | Promise; +} + +export function SkillDetailView({ skill, onSkillInstalled }: SkillDetailViewProps) { + const { getInstalledMatch, revalidate: revalidateInstalledSkillMatches } = useInstalledSkillMatches(); + const installedMatch = getInstalledMatch(skill); + const { content, frontmatter, isLoading } = useSkillContent(skill, true); + const { stats } = useRepoStats(skill, true); + const audits = useSkillAudits(skill, { + shouldFetch: true, + }); + const installCommand = buildInstallCommand(skill); + const allowedTools = normalizeAllowedTools(frontmatter["allowed-tools"]); + const shownErrorTimestampRef = useRef(undefined); + const refreshSkillStatus = useCallback(async () => { + await Promise.all([revalidateInstalledSkillMatches(), onSkillInstalled?.()]); + }, [revalidateInstalledSkillMatches, onSkillInstalled]); + + useEffect(() => { + if ( + audits.errorDetails !== undefined && + audits.errorDetails.skillSource === skill.source && + audits.errorDetails.skillId === skill.skillId && + audits.errorDetails.timestamp !== shownErrorTimestampRef.current + ) { + shownErrorTimestampRef.current = audits.errorDetails.timestamp; + void showSkillAuditErrorToast({ + error: audits.error ?? new Error("Unknown error"), + errorDetails: audits.errorDetails, + skillName: skill.name, + onRetry: audits.revalidate, + }); + } + }, [audits.error, audits.errorDetails, audits.revalidate, skill.name, skill.source, skill.skillId]); + + const markdown = isLoading + ? `# ${skill.name}\n\nLoading...` + : content + ? content + : `# ${skill.name} + +**Repository:** [${skill.source}](https://github.com/${skill.source}) + +**Installs:** ${formatInstalls(skill.installs)} + +--- + +\`\`\`bash +${installCommand} +\`\`\` +`; + + return ( + + {installedMatch.type === "exact" && ( + + + + )} + {installedMatch.type === "conflict" && ( + + + + )} + {installedMatch.type === "conflict" && ( + + )} + {installedMatch.type !== "none" && installedMatch.agents.length > 0 && ( + + {installedMatch.agents.map((agent) => ( + + ))} + + )} + {installedMatch.type !== "none" && } + {frontmatter.description && } + {frontmatter.description && } + + {stats?.rateLimited ? ( + + ) : ( + stats?.stars !== undefined && + stats?.stars !== null && ( + + ) + )} + {frontmatter.license && ( + + )} + {frontmatter.compatibility && ( + + )} + {allowedTools.length > 0 && ( + + {allowedTools.map((tool: string) => ( + + ))} + + )} + + + + + {audits.results.length > 0 ? ( + audits.results.map((audit) => + audit.url ? ( + + ) : ( + + ), + ) + ) : ( + + )} + + + + } + actions={ + + + + + + + + } + /> + ); +} diff --git a/extensions/skills/src/components/SkillListItem.tsx b/extensions/skills/src/components/SkillListItem.tsx index c8ae921a50d..bbd28fc0d44 100644 --- a/extensions/skills/src/components/SkillListItem.tsx +++ b/extensions/skills/src/components/SkillListItem.tsx @@ -1,237 +1,62 @@ import { Action, ActionPanel, Color, Icon, Keyboard, List } from "@raycast/api"; -import { useEffect, useRef } from "react"; -import { - type AuditStatus, - type SkillFrontmatter, - AUDIT_PROVIDER_LABELS, - buildInstallCommand, - formatInstalls, - normalizeAllowedTools, - Skill, - SkillAudit, - SKILLS_BASE_URL, -} from "../shared"; -import { useSkillContent } from "../hooks/useSkillContent"; -import { useRepoStats, type RepoStats } from "../hooks/useRepoStats"; -import { useSkillAudits } from "../hooks/useSkillAudits"; -import { type SkillAuditsAvailabilityState } from "../utils/skill-audits"; -import { showSkillAuditErrorToast } from "../utils/skill-audit-error-toast"; +import { buildInstallCommand, formatInstalls, type Skill, SKILLS_BASE_URL } from "../shared"; +import { type InstalledSkillMatch } from "../hooks/useInstalledSkillMatches"; import { InstallSkillAction } from "./actions/InstallSkillAction"; -import { OpenSecurityAuditActions } from "./actions/OpenSecurityAuditActions"; - -const AUDIT_STATUS_META: Record = { - pass: { emoji: "✅", label: "Pass" }, - warn: { emoji: "⚠️", label: "Warn" }, - fail: { emoji: "🛑", label: "Fail" }, - unknown: { emoji: "", label: "Unknown" }, -}; - -function formatAuditStatus(status: AuditStatus): string { - const { emoji, label } = AUDIT_STATUS_META[status]; - return emoji ? `${emoji} ${label}` : label; -} - -function getAuditFallbackText(isLoading: boolean, availabilityState?: SkillAuditsAvailabilityState): string { - if (isLoading) return "Loading..."; - if (availabilityState === "parse-error" || availabilityState === "fetch-error") return "Unable to verify"; - return "Pending"; -} - -interface InlineDetailProps { - skill: Skill; - content: string | undefined; - frontmatter: SkillFrontmatter; - isLoading: boolean; - stats: RepoStats | undefined; - audits: { - results: SkillAudit[]; - availabilityState?: SkillAuditsAvailabilityState; - isLoading: boolean; - }; -} - -function InlineDetail({ skill, content, frontmatter, isLoading, stats, audits }: InlineDetailProps) { - const installCommand = buildInstallCommand(skill); - const allowedTools = normalizeAllowedTools(frontmatter["allowed-tools"]); - - const markdown = isLoading - ? `# ${skill.name}\n\nLoading...` - : content - ? content - : `# ${skill.name} - -**Repository:** [${skill.source}](https://github.com/${skill.source}) - -**Installs:** ${formatInstalls(skill.installs)} - ---- - -\`\`\`bash -${installCommand} -\`\`\` -`; - - return ( - - {frontmatter.description && ( - - )} - {frontmatter.description && } - - {stats?.rateLimited ? ( - - ) : ( - stats?.stars !== undefined && - stats?.stars !== null && ( - - ) - )} - {frontmatter.license && ( - - )} - {frontmatter.compatibility && ( - - )} - {allowedTools.length > 0 && ( - - {allowedTools.map((tool: string) => ( - - ))} - - )} - - - - - {audits.results.length > 0 ? ( - audits.results.map((audit) => - audit.url ? ( - - ) : ( - - ), - ) - ) : ( - - )} - - - - } - /> - ); -} +import { SkillDetailView } from "./SkillDetailView"; interface SkillListItemProps { skill: Skill; rank?: number; - isSelected: boolean; - isInstalled?: boolean; - isShowingDetail: boolean; - onToggleDetail: () => void; + installedMatch: InstalledSkillMatch; + onViewedSkillChange: (skillId: string) => void; + onSkillInstalled?: () => void | Promise; } export function SkillListItem({ skill, rank, - isSelected, - isInstalled, - isShowingDetail, - onToggleDetail, + installedMatch, + onViewedSkillChange, + onSkillInstalled, }: SkillListItemProps) { const title = rank !== undefined && rank !== null ? `#${rank} ${skill.name}` : skill.name; - const { content, frontmatter, isLoading } = useSkillContent(skill, isSelected); - const { stats } = useRepoStats(skill, isSelected); - const audits = useSkillAudits(skill, { - shouldFetch: isSelected, - }); - - const icon = - rank !== undefined && rank !== null - ? { source: Icon.Trophy, tintColor: rank <= 3 ? Color.Yellow : Color.SecondaryText } - : { source: Icon.Hammer }; - - const accessories: List.Item.Accessory[] = []; - if (isInstalled) accessories.push({ tag: { value: "Installed", color: Color.Green } }); - if (!isShowingDetail) accessories.push({ text: formatInstalls(skill.installs), icon: Icon.Download }); - - const shownErrorTimestampRef = useRef(undefined); - - useEffect(() => { - if ( - isSelected && - audits.errorDetails !== undefined && - audits.errorDetails.skillSource === skill.source && - audits.errorDetails.skillId === skill.skillId && - audits.errorDetails.timestamp !== shownErrorTimestampRef.current - ) { - shownErrorTimestampRef.current = audits.errorDetails.timestamp; - void showSkillAuditErrorToast({ - error: audits.error ?? new Error("Unknown error"), - errorDetails: audits.errorDetails, - skillName: skill.name, - onRetry: audits.revalidate, - }); - } - }, [audits.error, audits.errorDetails, audits.revalidate, isSelected, skill.name, skill.source, skill.skillId]); + const isInstalled = installedMatch.type === "exact"; + const hasSourceConflict = installedMatch.type === "conflict"; + const installedSource = installedMatch.type === "conflict" ? (installedMatch.source ?? "Unknown source") : undefined; + + const iconValue = isInstalled + ? { source: Icon.CheckCircle, tintColor: Color.Green } + : hasSourceConflict + ? { source: Icon.Warning, tintColor: Color.Orange } + : rank !== undefined && rank !== null + ? { source: Icon.Trophy, tintColor: rank <= 3 ? Color.Yellow : Color.SecondaryText } + : { source: Icon.Hammer, tintColor: Color.SecondaryText }; + const iconTooltip = isInstalled + ? "Installed" + : hasSourceConflict + ? `Installed from source "${installedSource}"` + : undefined; + const icon = iconTooltip ? { value: iconValue, tooltip: iconTooltip } : iconValue; + + const accessories: List.Item.Accessory[] = [{ text: formatInstalls(skill.installs), icon: Icon.Download }]; return ( - } actions={ - + } + onPush={() => onViewedSkillChange(skill.id)} + /> + - - } /> diff --git a/extensions/skills/src/components/actions/InstallSkillAction.tsx b/extensions/skills/src/components/actions/InstallSkillAction.tsx index a12c13f0df3..c3f1cd597a7 100644 --- a/extensions/skills/src/components/actions/InstallSkillAction.tsx +++ b/extensions/skills/src/components/actions/InstallSkillAction.tsx @@ -1,15 +1,29 @@ -import { Action, ActionPanel, Alert, Form, Icon, showToast, Toast, confirmAlert, useNavigation } from "@raycast/api"; -import { showFailureToast } from "@raycast/utils"; -import { useState } from "react"; +import { + Action, + ActionPanel, + Alert, + Color, + Form, + Icon, + showToast, + Toast, + confirmAlert, + useNavigation, +} from "@raycast/api"; +import { useCallback, useState } from "react"; import { AUDIT_PROVIDER_LABELS, type Skill } from "../../shared"; import { useSkillAudits } from "../../hooks/useSkillAudits"; import { useAvailableAgents } from "../../hooks/useAvailableAgents"; +import { type InstalledSkillMatch } from "../../hooks/useInstalledSkillMatches"; import { type SkillAuditsResult, fetchSkillAudits } from "../../utils/skill-audits"; import { installSkill } from "../../utils/skills-cli"; +import { withSkillAction } from "../../utils/with-skill-action"; interface InstallSkillActionProps { skill: Skill; + installedMatch: InstalledSkillMatch; prefetchedAuditResult?: SkillAuditsResult; + onSkillInstalled?: () => void | Promise; } function joinWithAnd(items: string[]): string { @@ -18,30 +32,70 @@ function joinWithAnd(items: string[]): string { return `${items.slice(0, -1).join(", ")}, and ${items[items.length - 1]}`; } -function getConfirmationMessage(auditResult: SkillAuditsResult, agentLabel?: string): string { +function formatAgentSummary(agents: string[]): string { + if (agents.length === 0) return "the selected agents"; + if (agents.length <= 3) return joinWithAnd(agents); + return `${agents.length} agents`; +} + +type AuditRisk = "failed" | "unverified" | "pending" | "none"; + +const TITLE_SUFFIX_BY_RISK: Record = { + failed: " despite failed security audits", + unverified: " without verified security audits", + pending: " despite pending security audits", + none: "", +}; + +function getAuditRisk(auditResult: SkillAuditsResult): AuditRisk { const failedAudits = auditResult.audits.filter((audit) => audit.status === "fail"); - const hasFailedAudits = failedAudits.length > 0; - const failedProviders = hasFailedAudits - ? joinWithAnd(failedAudits.map((audit) => AUDIT_PROVIDER_LABELS[audit.provider])) - : ""; - const hasVerificationError = - auditResult.availabilityState === "fetch-error" || auditResult.availabilityState === "parse-error"; - const hasNoAudits = auditResult.availabilityState === "not-available" && auditResult.audits.length === 0; - - const reviewMessage = "Review the skill details before installing."; - if (hasFailedAudits) { + if (failedAudits.length > 0) return "failed"; + if (auditResult.availabilityState === "fetch-error" || auditResult.availabilityState === "parse-error") { + return "unverified"; + } + if (auditResult.availabilityState === "not-available" && auditResult.audits.length === 0) return "pending"; + return "none"; +} + +function getConfirmationMessage({ + auditResult, + auditRisk, + selectedAgents, + replacementAgents, + isReplacing, +}: { + auditResult: SkillAuditsResult; + auditRisk: AuditRisk; + selectedAgents: string[]; + replacementAgents: string[]; + isReplacing: boolean; +}): string { + const reviewMessage = `Review the skill details before ${isReplacing ? "replacing" : "installing"}.`; + + if (auditRisk === "failed") { + const failedProviders = joinWithAnd( + auditResult.audits + .filter((audit) => audit.status === "fail") + .map((audit) => AUDIT_PROVIDER_LABELS[audit.provider]), + ); return `Security audits by ${failedProviders} failed for this skill. ${reviewMessage}`; } - if (hasVerificationError) { + if (auditRisk === "unverified") { return `Security audit data could not be verified for this skill. ${reviewMessage}`; } - if (hasNoAudits) { + if (auditRisk === "pending") { return `Security audits are pending for this skill and its security status cannot be verified. ${reviewMessage}`; } - return agentLabel - ? `This will install the skill for ${agentLabel}.` - : "This will install the skill for all supported agents."; + if (isReplacing) { + const replacementSet = new Set(replacementAgents); + const additionalAgents = selectedAgents.filter((a) => !replacementSet.has(a)); + const replacePart = `This will replace the installed skill for ${formatAgentSummary(replacementAgents)}.`; + if (additionalAgents.length === 0) return replacePart; + return `${replacePart} It will also install the skill for ${formatAgentSummary(additionalAgents)}.`; + } + + return `This will install the skill for ${formatAgentSummary(selectedAgents)}.`; } async function hideToastSafely(toast?: Awaited>): Promise { @@ -68,27 +122,33 @@ async function resolveAuditResult(skill: Skill, cached?: SkillAuditsResult): Pro } } -function buildConfirmation(skill: Skill, auditResult: SkillAuditsResult, agentLabel?: string) { - const failedAudits = auditResult.audits.filter((a) => a.status === "fail"); - const hasFailedAudits = failedAudits.length > 0; - const hasVerificationError = - auditResult.availabilityState === "fetch-error" || auditResult.availabilityState === "parse-error"; - const hasNoAudits = auditResult.availabilityState === "not-available" && auditResult.audits.length === 0; - const requiresDestructiveConfirmation = hasFailedAudits || hasVerificationError || hasNoAudits; - const message = [getConfirmationMessage(auditResult, agentLabel), `Source: ${skill.source}`].join("\n\n"); +function buildConfirmation({ + skill, + auditResult, + selectedAgents, + replacementAgents, + isReplacing, +}: { + skill: Skill; + auditResult: SkillAuditsResult; + selectedAgents: string[]; + replacementAgents: string[]; + isReplacing: boolean; +}) { + const auditRisk = getAuditRisk(auditResult); + const operation = isReplacing ? "Replace" : "Install"; + const hasAuditRisk = auditRisk !== "none"; + const message = [ + getConfirmationMessage({ auditResult, auditRisk, selectedAgents, replacementAgents, isReplacing }), + `Source: ${skill.source}`, + ].join("\n\n"); return { - title: hasFailedAudits - ? `Install "${skill.name}" despite failed security audits?` - : hasVerificationError - ? `Install "${skill.name}" without verified security audits?` - : hasNoAudits - ? `Install "${skill.name}" despite pending security audits?` - : `Install "${skill.name}"?`, + title: `${operation} "${skill.name}"${TITLE_SUFFIX_BY_RISK[auditRisk]}?`, message, primaryAction: { - title: requiresDestructiveConfirmation ? "Install anyway" : "Install", - style: requiresDestructiveConfirmation ? Alert.ActionStyle.Destructive : Alert.ActionStyle.Default, + title: hasAuditRisk ? `${operation} anyway` : operation, + style: isReplacing || hasAuditRisk ? Alert.ActionStyle.Destructive : Alert.ActionStyle.Default, }, }; } @@ -96,21 +156,28 @@ function buildConfirmation(skill: Skill, auditResult: SkillAuditsResult, agentLa interface AgentPickerInstallFormProps { skill: Skill; agents: string[]; - installedAgents: string[]; + installedMatch: InstalledSkillMatch; prefetchedAuditResult?: SkillAuditsResult; + onSkillInstalled?: () => void | Promise; } function AgentPickerInstallForm({ skill, agents, - installedAgents, + installedMatch, prefetchedAuditResult, + onSkillInstalled, }: AgentPickerInstallFormProps) { const { pop } = useNavigation(); - const installedSet = new Set(installedAgents); - const selectableAgents = agents.filter((a) => !installedSet.has(a)); + const installedAgentNames = installedMatch.type === "none" ? [] : installedMatch.agents; + const installedAgents = new Set(installedAgentNames); + const replacementAgentNames = installedMatch.type === "conflict" ? installedAgentNames : []; + const selectableAgents = agents.filter((a) => !installedAgents.has(a)); const [selected, setSelected] = useState>(new Set()); const allSelected = selectableAgents.length > 0 && selected.size === selectableAgents.length; + const isReplacing = installedMatch.type === "conflict"; + const installedSource = isReplacing ? (installedMatch.source ?? "Unknown source") : ""; + const submitTitle = isReplacing ? "Replace Installed Skill" : "Install Skill"; function toggleAll() { setSelected(allSelected ? new Set() : new Set(selectableAgents)); @@ -126,54 +193,74 @@ function AgentPickerInstallForm({ } async function handleSubmit() { - if (selected.size === 0) { + const selectedAgents = Array.from(new Set([...replacementAgentNames, ...selected])); + if (selectedAgents.length === 0) { await showToast({ style: Toast.Style.Failure, title: "Select at least one agent" }); return; } - const selectedAgents = [...selected]; - const agentLabel = joinWithAnd(selectedAgents); const auditResult = await resolveAuditResult(skill, prefetchedAuditResult); - const confirmed = await confirmAlert(buildConfirmation(skill, auditResult, agentLabel)); - if (!confirmed) return; + const { title, message, primaryAction } = buildConfirmation({ + skill, + auditResult, + selectedAgents, + replacementAgents: replacementAgentNames, + isReplacing, + }); - const toast = await showToast({ - style: Toast.Style.Animated, - title: "Installing skill...", - message: skill.name, + const confirmed = await confirmAlert({ + title, + message, + primaryAction, }); + if (!confirmed) return; - try { - pop(); - await installSkill(skill, selectedAgents); - toast.style = Toast.Style.Success; - toast.title = "Skill installed successfully"; - toast.message = `${skill.name} is now available`; - } catch (error) { - await toast.hide(); - await showFailureToast(error, { title: "Failed to install skill" }); - } + pop(); + + await withSkillAction({ + toast: { + animatedTitle: "Installing skill...", + successTitle: "Skill installed successfully", + successMessage: `${skill.name} is now available`, + failureTitle: "Failed to install skill", + }, + operation: () => installSkill(skill, selectedAgents), + onSuccess: onSkillInstalled, + }); } return (
- + } > - + + {isReplacing && ( + <> + + + + + )} {agents.map((agent) => { - const isInstalled = installedSet.has(agent); + const isInstalled = installedAgents.has(agent); + const label = isInstalled ? `${agent} (installed)` : agent; return ( { if (!isInstalled) toggleAgent(agent, checked); @@ -185,28 +272,45 @@ function AgentPickerInstallForm({ ); } -export function InstallSkillAction({ skill, prefetchedAuditResult }: InstallSkillActionProps) { - const { agents, skillAgentMap } = useAvailableAgents(); +export function InstallSkillAction({ + skill, + installedMatch, + prefetchedAuditResult, + onSkillInstalled, +}: InstallSkillActionProps) { + const { agents, revalidate } = useAvailableAgents(); + const { push } = useNavigation(); const { result: cachedAuditResult } = useSkillAudits(skill, { shouldFetch: false, initialData: prefetchedAuditResult, }); - // skillAgentMap is keyed by the CLI's installed skill name, which matches - // the skillId used in `skills add source@skillId`. - const installedAgents = skillAgentMap[skill.skillId] ?? []; - return ( - - } + const afterInstall = useCallback(async () => { + await Promise.all([revalidate(), onSkillInstalled?.()]); + }, [revalidate, onSkillInstalled]); + + const form = ( + ); + + if (installedMatch.type === "conflict") { + return ( + { + push(form); + }} + /> + ); + } + + return ; } diff --git a/extensions/skills/src/components/actions/RemoveSkillAction.tsx b/extensions/skills/src/components/actions/RemoveSkillAction.tsx index cd590ee0d89..b739a8c527a 100644 --- a/extensions/skills/src/components/actions/RemoveSkillAction.tsx +++ b/extensions/skills/src/components/actions/RemoveSkillAction.tsx @@ -1,19 +1,19 @@ import { Action, ActionPanel, + Alert, Form, Icon, Color, useNavigation, confirmAlert, - Alert, showToast, Toast, } from "@raycast/api"; -import { showFailureToast } from "@raycast/utils"; import { useState } from "react"; import type { InstalledSkill } from "../../shared"; import { removeSkill } from "../../utils/skills-cli"; +import { withSkillAction } from "../../utils/with-skill-action"; import type { MutateSkills } from "../../hooks/useInstalledSkills"; interface RemoveSkillActionProps { @@ -33,11 +33,8 @@ function AgentPickerForm({ skill, mutate }: RemoveSkillActionProps) { function toggleAgent(agent: string, checked: boolean) { setSelected((prev) => { const next = new Set(prev); - if (checked) { - next.add(agent); - } else { - next.delete(agent); - } + if (checked) next.add(agent); + else next.delete(agent); return next; }); } @@ -51,6 +48,7 @@ function AgentPickerForm({ skill, mutate }: RemoveSkillActionProps) { const agents = [...selected]; const isAll = agents.length === skill.agents.length; const label = isAll ? "all agents" : agents.join(", "); + const confirmed = await confirmAlert({ title: isAll ? `Remove "${skill.name}"?` : `Remove "${skill.name}" from ${label}?`, message: isAll @@ -60,29 +58,34 @@ function AgentPickerForm({ skill, mutate }: RemoveSkillActionProps) { }); if (!confirmed) return; - const toast = await showToast({ style: Toast.Style.Animated, title: "Removing skill..." }); - try { - const removedSet = new Set(agents); - pop(); - await mutate(removeSkill(skill.name, isAll ? undefined : agents), { - optimisticUpdate: (skills) => { - if (!skills) return []; - if (isAll) return skills.filter((s) => s.name !== skill.name); - return skills - .map((s) => - s.name === skill.name - ? { ...s, agents: s.agents.filter((a) => !removedSet.has(a)), agentCount: s.agentCount - agents.length } - : s, - ) - .filter((s) => s.agents.length > 0); - }, - }); - toast.style = Toast.Style.Success; - toast.title = isAll ? "Skill removed" : `Skill removed from ${label}`; - } catch (error) { - await toast.hide(); - await showFailureToast(error, { title: "Failed to remove skill" }); - } + pop(); + + await withSkillAction({ + toast: { + animatedTitle: "Removing skill...", + successTitle: isAll ? "Skill removed" : `Skill removed from ${label}`, + failureTitle: "Failed to remove skill", + }, + operation: () => + mutate(removeSkill(skill.name, isAll ? undefined : agents), { + optimisticUpdate: (skills) => { + if (!skills) return []; + if (isAll) return skills.filter((s) => s.name !== skill.name); + const removedSet = new Set(agents); + return skills + .map((s) => + s.name === skill.name + ? { + ...s, + agents: s.agents.filter((a) => !removedSet.has(a)), + agentCount: s.agentCount - agents.length, + } + : s, + ) + .filter((s) => s.agents.length > 0); + }, + }), + }); } return ( @@ -128,26 +131,24 @@ export function RemoveSkillAction({ skill, mutate }: RemoveSkillActionProps) { icon={Icon.Trash} style={Action.Style.Destructive} shortcut={{ modifiers: ["ctrl"], key: "x" }} - onAction={async () => { - const confirmed = await confirmAlert({ - title: `Remove "${skill.name}"?`, - message: "This will remove the skill from all agents.", - primaryAction: { title: "Remove", style: Alert.ActionStyle.Destructive }, - }); - if (!confirmed) return; - - const toast = await showToast({ style: Toast.Style.Animated, title: "Removing skill..." }); - try { - await mutate(removeSkill(skill.name), { - optimisticUpdate: (skills) => (skills ? skills.filter((s) => s.name !== skill.name) : []), - }); - toast.style = Toast.Style.Success; - toast.title = "Skill removed"; - } catch (error) { - await toast.hide(); - await showFailureToast(error, { title: "Failed to remove skill" }); - } - }} + onAction={() => + withSkillAction({ + confirm: { + title: `Remove "${skill.name}"?`, + message: "This will remove the skill from all agents.", + primaryAction: { title: "Remove", style: Alert.ActionStyle.Destructive }, + }, + toast: { + animatedTitle: "Removing skill...", + successTitle: "Skill removed", + failureTitle: "Failed to remove skill", + }, + operation: () => + mutate(removeSkill(skill.name), { + optimisticUpdate: (skills) => (skills ? skills.filter((s) => s.name !== skill.name) : []), + }), + }) + } /> ); } diff --git a/extensions/skills/src/components/actions/UpdateSkillAction.tsx b/extensions/skills/src/components/actions/UpdateSkillAction.tsx index 0622c421439..17f194573e0 100644 --- a/extensions/skills/src/components/actions/UpdateSkillAction.tsx +++ b/extensions/skills/src/components/actions/UpdateSkillAction.tsx @@ -1,10 +1,9 @@ -import { Action, Icon, confirmAlert, Alert, showToast, Toast } from "@raycast/api"; -import { showFailureToast } from "@raycast/utils"; +import { Action, Alert, Icon } from "@raycast/api"; import { updateAllSkills, updateSkill } from "../../utils/skills-cli"; +import { withSkillAction } from "../../utils/with-skill-action"; import type { MutateSkills } from "../../hooks/useInstalledSkills"; interface UpdateSkillActionProps { - /** When provided, updates just this skill. Otherwise, updates all installed skills. */ skillName?: string; mutate: MutateSkills; } @@ -17,36 +16,29 @@ export function UpdateSkillAction({ skillName, mutate }: UpdateSkillActionProps) title={isSingle ? "Update Skill" : "Update All Skills"} icon={Icon.ArrowClockwise} shortcut={{ modifiers: ["cmd", "shift"], key: "u" }} - onAction={async () => { - const confirmed = await confirmAlert({ - title: isSingle ? `Update "${skillName}"?` : "Update All Skills?", - message: isSingle - ? `This will update "${skillName}" to the latest version.` - : "This will update all installed skills to the latest version.", - primaryAction: { title: "Update", style: Alert.ActionStyle.Default }, - }); - if (!confirmed) return; - - const toast = await showToast({ - style: Toast.Style.Animated, - title: isSingle ? "Updating skill..." : "Updating skills...", - }); - try { - await mutate(isSingle ? updateSkill(skillName) : updateAllSkills(), { - optimisticUpdate: (skills) => { - if (!skills) return []; - return skills.map((s) => (!isSingle || s.name === skillName ? { ...s, hasUpdate: false } : s)); - }, - }); - toast.style = Toast.Style.Success; - toast.title = isSingle ? "Skill updated" : "All skills updated"; - } catch (error) { - await toast.hide(); - await showFailureToast(error, { - title: isSingle ? "Failed to update skill" : "Failed to update skills", - }); - } - }} + onAction={() => + withSkillAction({ + confirm: { + title: isSingle ? `Update "${skillName}"?` : "Update All Skills?", + message: isSingle + ? `This will update "${skillName}" to the latest version.` + : "This will update all installed skills to the latest version.", + primaryAction: { title: "Update", style: Alert.ActionStyle.Default }, + }, + toast: { + animatedTitle: isSingle ? "Updating skill..." : "Updating skills...", + successTitle: isSingle ? "Skill updated" : "All skills updated", + failureTitle: isSingle ? "Failed to update skill" : "Failed to update skills", + }, + operation: () => + mutate(isSingle ? updateSkill(skillName) : updateAllSkills(), { + optimisticUpdate: (skills) => { + if (!skills) return []; + return skills.map((s) => (!isSingle || s.name === skillName ? { ...s, hasUpdate: false } : s)); + }, + }), + }) + } /> ); } diff --git a/extensions/skills/src/hooks/useAvailableAgents.ts b/extensions/skills/src/hooks/useAvailableAgents.ts index 0aff9398cf7..2b2b6a4bdd8 100644 --- a/extensions/skills/src/hooks/useAvailableAgents.ts +++ b/extensions/skills/src/hooks/useAvailableAgents.ts @@ -4,7 +4,7 @@ import { discoverAgents, KNOWN_AGENT_NAMES } from "../utils/skills-cli"; const INITIAL_DATA = { agents: KNOWN_AGENT_NAMES, skillAgentMap: {} }; export function useAvailableAgents() { - const { data, isLoading } = useCachedPromise(discoverAgents, [], { + const { data, isLoading, revalidate } = useCachedPromise(discoverAgents, [], { keepPreviousData: true, initialData: INITIAL_DATA, }); @@ -12,5 +12,6 @@ export function useAvailableAgents() { agents: data?.agents ?? KNOWN_AGENT_NAMES, skillAgentMap: data?.skillAgentMap ?? {}, isLoading, + revalidate, }; } diff --git a/extensions/skills/src/hooks/useInstalledSkillMatches.ts b/extensions/skills/src/hooks/useInstalledSkillMatches.ts new file mode 100644 index 00000000000..632ec0ec5ca --- /dev/null +++ b/extensions/skills/src/hooks/useInstalledSkillMatches.ts @@ -0,0 +1,65 @@ +import { useCallback, useMemo } from "react"; +import { useCachedPromise } from "@raycast/utils"; +import { getInstalledSkillsWithLock } from "../utils/installed-skills"; +import { stripGitSuffix, type InstalledSkill, type Skill } from "../shared"; + +export type InstalledSkillMatch = + | { type: "none" } + | { type: "exact"; agents: string[]; source: string } + | { type: "conflict"; agents: string[]; source?: string }; + +function normalizeSkillIdentityPart(value: string): string { + return value.trim().toLowerCase(); +} + +function normalizeGithubSource(source: string | undefined): string | undefined { + if (!source) return undefined; + + const normalized = stripGitSuffix(source.trim()) + .replace(/^https?:\/\/github\.com\//i, "") + .replace(/^git@github\.com:/i, "") + .replace(/^\/+/, "") + .replace(/\/+$/, ""); + + return normalized ? normalizeSkillIdentityPart(normalized) : undefined; +} + +export function useInstalledSkillMatches() { + const { data, isLoading, revalidate } = useCachedPromise(getInstalledSkillsWithLock, [], { + keepPreviousData: true, + }); + + const installedByName = useMemo(() => { + const map = new Map(); + for (const record of data ?? []) { + map.set(normalizeSkillIdentityPart(record.name), record); + } + return map; + }, [data]); + + const getInstalledMatch = useCallback( + (skill: Skill): InstalledSkillMatch => { + const installed = installedByName.get(normalizeSkillIdentityPart(skill.skillId)); + if (!installed) return { type: "none" }; + + const installedSource = normalizeGithubSource(installed.source); + if (!installedSource) { + return { type: "conflict", agents: installed.agents, source: installed.source }; + } + + const candidateSource = normalizeGithubSource(skill.source); + if (candidateSource && installedSource === candidateSource) { + return { type: "exact", agents: installed.agents, source: installed.source ?? skill.source }; + } + + return { type: "conflict", agents: installed.agents, source: installed.source }; + }, + [installedByName], + ); + + return { + getInstalledMatch, + isLoading, + revalidate, + }; +} diff --git a/extensions/skills/src/hooks/useInstalledSkillNames.ts b/extensions/skills/src/hooks/useInstalledSkillNames.ts deleted file mode 100644 index 18dd319458d..00000000000 --- a/extensions/skills/src/hooks/useInstalledSkillNames.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { useMemo } from "react"; -import { useCachedPromise } from "@raycast/utils"; -import { listInstalledSkills } from "../utils/skills-cli"; - -async function fetchInstalledSkillNames(): Promise { - const skills = await listInstalledSkills(); - return skills.map((s) => s.name); -} - -export function useInstalledSkillNames() { - const { data, isLoading } = useCachedPromise(fetchInstalledSkillNames, [], { - keepPreviousData: true, - }); - - const installedNames = useMemo(() => new Set(data ?? []), [data]); - - return { - installedNames, - isLoading, - }; -} diff --git a/extensions/skills/src/hooks/useInstalledSkills.ts b/extensions/skills/src/hooks/useInstalledSkills.ts index 8f49b28a7c1..f864e9568d8 100644 --- a/extensions/skills/src/hooks/useInstalledSkills.ts +++ b/extensions/skills/src/hooks/useInstalledSkills.ts @@ -1,29 +1,16 @@ import { useCachedPromise, type MutatePromise } from "@raycast/utils"; -import { listInstalledSkills, checkForUpdates, readSkillLock } from "../utils/skills-cli"; -import { type InstalledSkill, stripGitSuffix } from "../shared"; +import { checkForUpdates, getInstalledSkillsWithLock } from "../utils/installed-skills"; +import { type InstalledSkill } from "../shared"; export type MutateSkills = MutatePromise; async function fetchSkillsWithUpdateStatus(): Promise { - const [skills, updatable, lockEntries] = await Promise.all([ - listInstalledSkills(), - checkForUpdates().catch(() => [] as string[]), - readSkillLock(), + const [skills, updatable] = await Promise.all([ + getInstalledSkillsWithLock(), + checkForUpdates().catch((): string[] => []), ]); const updatableSet = new Set(updatable); - return skills.map((skill) => { - const lock = lockEntries[skill.name]; - return { - ...skill, - hasUpdate: updatableSet.has(skill.name), - ...(lock && { - source: lock.source, - sourceUrl: lock.sourceUrl ? stripGitSuffix(lock.sourceUrl) : undefined, - installedAt: lock.installedAt, - updatedAt: lock.updatedAt, - }), - }; - }); + return skills.map((skill) => ({ ...skill, hasUpdate: updatableSet.has(skill.name) })); } export function useInstalledSkills() { diff --git a/extensions/skills/src/manage-skills.tsx b/extensions/skills/src/manage-skills.tsx index 22f26564b23..454fbfdfaae 100644 --- a/extensions/skills/src/manage-skills.tsx +++ b/extensions/skills/src/manage-skills.tsx @@ -84,7 +84,12 @@ export default function Command() { icon={Icon.Box} actions={ - + } /> @@ -95,7 +100,12 @@ export default function Command() { icon={Icon.Filter} actions={ - + } /> @@ -117,6 +127,12 @@ export default function Command() { actions={ + } /> @@ -131,6 +147,7 @@ export default function Command() { isShowingDetail={isShowingDetail} mutate={mutate} onToggleDetail={toggleDetail} + onRefresh={revalidate} /> ))} diff --git a/extensions/skills/src/search-skills.tsx b/extensions/skills/src/search-skills.tsx index 0baccd3dbd9..1e06be9058b 100644 --- a/extensions/skills/src/search-skills.tsx +++ b/extensions/skills/src/search-skills.tsx @@ -1,8 +1,8 @@ import { List, ActionPanel, Action, Detail, Icon } from "@raycast/api"; -import { useState } from "react"; +import { useCallback, useState } from "react"; import { SkillListItem } from "./components/SkillListItem"; -import { useInstalledSkillNames } from "./hooks/useInstalledSkillNames"; +import { useInstalledSkillMatches } from "./hooks/useInstalledSkillMatches"; import { useOwnerFilter } from "./hooks/useOwnerFilter"; import { useDebouncedSearch } from "./hooks/useDebouncedSearch"; import { buildGithubIssueUrl } from "./shared"; @@ -10,21 +10,22 @@ import { buildGithubIssueUrl } from "./shared"; export default function Command() { const [searchText, setSearchText] = useState(""); const [selectedId, setSelectedId] = useState(null); - const [isShowingDetail, setIsShowingDetail] = useState(true); - const toggleDetail = () => setIsShowingDetail((prev) => !prev); - - const { data, isLoading, error, revalidate, searchUrl } = useDebouncedSearch(searchText); - const { installedNames } = useInstalledSkillNames(); + const { data, isLoading, error, revalidate: revalidateSearch, searchUrl } = useDebouncedSearch(searchText); + const { getInstalledMatch, revalidate: revalidateInstalledSkillMatches } = useInstalledSkillMatches(); const { owner, setOwner, ownerCounts, skills } = useOwnerFilter(data?.skills ?? []); + const refreshCurrentResults = useCallback(async () => { + await Promise.all([revalidateSearch(), revalidateInstalledSkillMatches()]); + }, [revalidateSearch, revalidateInstalledSkillMatches]); + if (error && !data) { return ( - + 0 && isShowingDetail} + selectedItemId={selectedId ?? undefined} searchBarAccessory={ @@ -76,7 +77,7 @@ export default function Command() { icon={Icon.MagnifyingGlass} actions={ - + } /> @@ -86,10 +87,9 @@ export default function Command() { ))} diff --git a/extensions/skills/src/utils/installed-skills.ts b/extensions/skills/src/utils/installed-skills.ts new file mode 100644 index 00000000000..27c5c3d8cf6 --- /dev/null +++ b/extensions/skills/src/utils/installed-skills.ts @@ -0,0 +1,109 @@ +import { readFile } from "node:fs/promises"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { getGithubToken } from "../preferences"; +import { stripGitSuffix, type InstalledSkill, type SkillLockEntry } from "../shared"; +import { listInstalledSkills } from "./skills-cli"; + +const LOCK_FILE = ".skill-lock.json"; +const AGENTS_DIR = ".agents"; + +function getSkillLockPath(): string { + const xdgStateHome = process.env.XDG_STATE_HOME; + if (xdgStateHome) return join(xdgStateHome, "skills", LOCK_FILE); + return join(homedir(), AGENTS_DIR, LOCK_FILE); +} + +async function readSkillLock(): Promise> { + try { + const raw = await readFile(getSkillLockPath(), "utf-8"); + const parsed = JSON.parse(raw); + if (parsed && typeof parsed.skills === "object" && parsed.skills !== null) { + return parsed.skills as Record; + } + return {}; + } catch { + return {}; + } +} + +export async function getInstalledSkillsWithLock(): Promise { + const [skills, lockEntries] = await Promise.all([listInstalledSkills(), readSkillLock()]); + return skills.map((skill) => { + const lock = lockEntries[skill.name]; + if (!lock) return skill; + return { + ...skill, + source: lock.source, + sourceUrl: lock.sourceUrl ? stripGitSuffix(lock.sourceUrl) : undefined, + installedAt: lock.installedAt, + updatedAt: lock.updatedAt, + }; + }); +} + +interface GitHubTreeResponse { + sha: string; + tree: Array<{ path: string; sha: string; type: string }>; + truncated?: boolean; +} + +async function fetchRepoTree(source: string, token: string | undefined): Promise { + const [owner, repo] = source.split("/"); + if (!owner || !repo) return null; + + const headers: Record = { + Accept: "application/vnd.github+json", + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }; + + for (const branch of ["main", "master"]) { + const res = await fetch(`https://api.github.com/repos/${owner}/${repo}/git/trees/${branch}?recursive=1`, { + headers, + }); + if (res.ok) { + const data = (await res.json()) as GitHubTreeResponse; + if (data.truncated) return null; + return data; + } + if (res.status === 403 || res.status === 429) return null; + } + return null; +} + +/** + * Implemented against the GitHub Trees API rather than `npx skills check` because + * the CLI's check command reinstalls outdated skills as a side effect since v1.5.0. + */ +export async function checkForUpdates(): Promise { + const lock = await readSkillLock(); + const byRepo = new Map>(); + + for (const [name, entry] of Object.entries(lock)) { + if (entry.sourceType !== "github" || !entry.skillFolderHash || !entry.skillPath) continue; + const list = byRepo.get(entry.source) ?? []; + list.push({ name, skillPath: entry.skillPath, expectedHash: entry.skillFolderHash }); + byRepo.set(entry.source, list); + } + if (byRepo.size === 0) return []; + + const githubToken = getGithubToken(); + + const results = await Promise.all( + [...byRepo.entries()].map(async ([source, skills]) => { + try { + const tree = await fetchRepoTree(source, githubToken); + if (!tree) return []; + const { sha: rootSha, tree: entries } = tree; + return skills.flatMap((skill) => { + const folder = skill.skillPath.replace(/\/?SKILL\.md$/, ""); + const upstreamSha = folder ? entries.find((t) => t.path === folder && t.type === "tree")?.sha : rootSha; + return upstreamSha && upstreamSha !== skill.expectedHash ? [skill.name] : []; + }); + } catch { + return []; + } + }), + ); + return results.flat(); +} diff --git a/extensions/skills/src/utils/skills-cli.ts b/extensions/skills/src/utils/skills-cli.ts index 65d9dcad202..28262cb0ef3 100644 --- a/extensions/skills/src/utils/skills-cli.ts +++ b/extensions/skills/src/utils/skills-cli.ts @@ -1,9 +1,9 @@ -import { access, readFile, stat } from "node:fs/promises"; +import { access, stat } from "node:fs/promises"; import { constants } from "node:fs"; import { homedir } from "node:os"; -import { basename, join } from "node:path"; -import { getCustomNpxPath, getGithubToken, shouldDisableSkillsCliTelemetry } from "../preferences"; -import type { InstalledSkill, Skill, SkillLockEntry } from "../shared"; +import { basename } from "node:path"; +import { getCustomNpxPath, shouldDisableSkillsCliTelemetry } from "../preferences"; +import type { InstalledSkill, Skill } from "../shared"; import { execAsync } from "./exec-async"; import { getExecOptions } from "./exec-options"; @@ -347,72 +347,6 @@ export async function removeSkill(skillName: string, agentDisplayNames?: string[ await runSkillsCli(args); } -interface GitHubTreeResponse { - sha: string; - tree: Array<{ path: string; sha: string; type: string }>; - truncated?: boolean; -} - -async function fetchRepoTree(source: string, token: string | undefined): Promise { - const [owner, repo] = source.split("/"); - if (!owner || !repo) return null; - - const headers: Record = { - Accept: "application/vnd.github+json", - ...(token ? { Authorization: `Bearer ${token}` } : {}), - }; - - for (const branch of ["main", "master"]) { - const res = await fetch(`https://api.github.com/repos/${owner}/${repo}/git/trees/${branch}?recursive=1`, { - headers, - }); - if (res.ok) { - const data = (await res.json()) as GitHubTreeResponse; - if (data.truncated) return null; - return data; - } - if (res.status === 403 || res.status === 429) return null; - } - return null; -} - -/** - * Implemented against the GitHub Trees API rather than `npx skills check` because - * the CLI's check command reinstalls outdated skills as a side effect since v1.5.0. - */ -export async function checkForUpdates(): Promise { - const lock = await readSkillLock(); - const entries = Object.entries(lock).filter(([, e]) => e.sourceType === "github" && e.skillFolderHash && e.skillPath); - if (entries.length === 0) return []; - - const byRepo = new Map>(); - for (const [name, entry] of entries) { - const list = byRepo.get(entry.source) ?? []; - list.push({ name, skillPath: entry.skillPath, expectedHash: entry.skillFolderHash }); - byRepo.set(entry.source, list); - } - - const githubToken = getGithubToken(); - - const results = await Promise.all( - [...byRepo.entries()].map(async ([source, skills]) => { - try { - const tree = await fetchRepoTree(source, githubToken); - if (!tree) return []; - const { sha: rootSha, tree: entries } = tree; - return skills.flatMap((skill) => { - const folder = skill.skillPath.replace(/\/?SKILL\.md$/, ""); - const upstreamSha = folder ? entries.find((t) => t.path === folder && t.type === "tree")?.sha : rootSha; - return upstreamSha && upstreamSha !== skill.expectedHash ? [skill.name] : []; - }); - } catch { - return []; - } - }), - ); - return results.flat(); -} - /** * Update all installed skills. */ @@ -427,25 +361,3 @@ export async function updateAllSkills(): Promise { export async function updateSkill(skillName: string): Promise { await runSkillsCli(["update", skillName, "-y"]); } - -const LOCK_FILE = ".skill-lock.json"; -const AGENTS_DIR = ".agents"; - -function getSkillLockPath(): string { - const xdgStateHome = process.env.XDG_STATE_HOME; - if (xdgStateHome) return join(xdgStateHome, "skills", LOCK_FILE); - return join(home, AGENTS_DIR, LOCK_FILE); -} - -export async function readSkillLock(): Promise> { - try { - const raw = await readFile(getSkillLockPath(), "utf-8"); - const parsed = JSON.parse(raw); - if (parsed && typeof parsed.skills === "object" && parsed.skills !== null) { - return parsed.skills as Record; - } - return {}; - } catch { - return {}; - } -} diff --git a/extensions/skills/src/utils/with-skill-action.ts b/extensions/skills/src/utils/with-skill-action.ts new file mode 100644 index 00000000000..afeb849d379 --- /dev/null +++ b/extensions/skills/src/utils/with-skill-action.ts @@ -0,0 +1,60 @@ +import { confirmAlert, Alert, showToast, Toast } from "@raycast/api"; +import { showFailureToast } from "@raycast/utils"; + +interface WithSkillActionOptions { + confirm?: { + title: string; + message: string; + primaryAction?: { title: string; style: Alert.ActionStyle }; + }; + toast: { + animatedTitle: string; + successTitle: string; + successMessage?: string; + failureTitle: string; + }; + operation: () => Promise; + onSuccess?: (result: T) => void | Promise; +} + +export async function withSkillAction({ + confirm: confirmOpts, + toast: toastOpts, + operation, + onSuccess, +}: WithSkillActionOptions): Promise { + if (confirmOpts) { + const confirmed = await confirmAlert({ + title: confirmOpts.title, + message: confirmOpts.message, + primaryAction: confirmOpts.primaryAction ?? { title: "Confirm", style: Alert.ActionStyle.Default }, + }); + if (!confirmed) return; + } + + const toast = await showToast({ + style: Toast.Style.Animated, + title: toastOpts.animatedTitle, + }); + + let result: T; + try { + result = await operation(); + } catch (error) { + await toast.hide(); + await showFailureToast(error, { title: toastOpts.failureTitle }); + return; + } + + toast.style = Toast.Style.Success; + toast.title = toastOpts.successTitle; + if (toastOpts.successMessage) toast.message = toastOpts.successMessage; + + if (onSuccess) { + try { + await onSuccess(result); + } catch (error) { + console.error("[skills] onSuccess handler failed after a successful operation:", error); + } + } +}