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
-
-
-
+
+
+
+
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);
+ }
+ }
+}