Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions extensions/skills/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
# Skills Changelog

## [Serialize Concurrent CLI Commands] - 2026-08-24

- Prevent simultaneous Raycast commands from racing in the shared `npx` cache and intermittently failing with `ENOTEMPTY`
- Read the installed-skill list and metadata from one locked snapshot so Manage Skills does not show mismatched data

## [Updated contributor] - 2026-08-18

## [Fix Runtime Detection and Skill Lookup] - 2026-07-30
Expand Down
46 changes: 45 additions & 1 deletion extensions/skills/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions extensions/skills/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -109,13 +109,15 @@
"dependencies": {
"@raycast/api": "^1.104.16",
"@raycast/utils": "^2.2.4",
"proper-lockfile": "^4.1.2",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"semver": "^7.7.3"
},
"devDependencies": {
"@raycast/eslint-config": "^2.1.1",
"@types/node": "^25.2.3",
"@types/proper-lockfile": "^4.1.4",
"@types/react": "^19.2.13",
"@types/semver": "^7.7.1",
"eslint": "^10.0.0",
Expand Down
9 changes: 2 additions & 7 deletions extensions/skills/src/hooks/useInstalledSkills.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,11 @@
import { useCachedPromise, type MutatePromise } from "@raycast/utils";
import { checkForUpdates, getInstalledSkillsWithLock } from "../utils/installed-skills";
import { getInstalledSkillsWithUpdateStatus } from "../utils/installed-skills";
import { type InstalledSkill } from "../shared";

export type MutateSkills = MutatePromise<InstalledSkill[] | undefined>;

async function fetchSkillsWithUpdateStatus(): Promise<InstalledSkill[]> {
const [skills, updatable] = await Promise.all([
getInstalledSkillsWithLock(),
checkForUpdates().catch((): string[] => []),
]);
const updatableSet = new Set(updatable);
return skills.map((skill) => ({ ...skill, hasUpdate: updatableSet.has(skill.name) }));
return getInstalledSkillsWithUpdateStatus();
}

export function useInstalledSkills() {
Expand Down
27 changes: 24 additions & 3 deletions extensions/skills/src/utils/installed-skills.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { join } from "node:path";
import { getGithubToken } from "../preferences";
import { stripGitSuffix, type InstalledSkill, type SkillLockEntry } from "../shared";
import { listInstalledSkills } from "./skills-cli";
import { withSkillsCliLock } from "./skills-cli-runner";

const LOCK_FILE = ".skill-lock.json";
const AGENTS_DIR = ".agents";
Expand All @@ -28,7 +29,21 @@ async function readSkillLock(): Promise<Record<string, SkillLockEntry>> {
}

export async function getInstalledSkillsWithLock(): Promise<InstalledSkill[]> {
const [skills, lockEntries] = await Promise.all([listInstalledSkills(), readSkillLock()]);
return (await getInstalledSkillsSnapshot()).skills;
}

async function getInstalledSkillsSnapshot(): Promise<{
skills: InstalledSkill[];
lockEntries: Record<string, SkillLockEntry>;
}> {
return withSkillsCliLock(async (runLocked) => {
const skills = await listInstalledSkills(runLocked);
const lockEntries = await readSkillLock();
return { skills: mergeLockEntries(skills, lockEntries), lockEntries };
});
}

function mergeLockEntries(skills: InstalledSkill[], lockEntries: Record<string, SkillLockEntry>): InstalledSkill[] {
return skills.map((skill) => {
const lock = lockEntries[skill.name];
if (!lock) return skill;
Expand Down Expand Up @@ -77,8 +92,7 @@ async function fetchRepoTree(source: string, token: string | undefined): Promise
* 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<string[]> {
const lock = await readSkillLock();
async function checkForUpdates(lock: Record<string, SkillLockEntry>): Promise<string[]> {
const byRepo = new Map<string, Array<{ name: string; skillPath: string; expectedHash: string }>>();

for (const [name, entry] of Object.entries(lock)) {
Expand Down Expand Up @@ -109,3 +123,10 @@ export async function checkForUpdates(): Promise<string[]> {
);
return results.flat();
}

export async function getInstalledSkillsWithUpdateStatus(): Promise<InstalledSkill[]> {
const { skills, lockEntries } = await getInstalledSkillsSnapshot();
const updatable = await checkForUpdates(lockEntries).catch((): string[] => []);
const updatableSet = new Set(updatable);
return skills.map((skill) => ({ ...skill, hasUpdate: updatableSet.has(skill.name) }));
}
33 changes: 30 additions & 3 deletions extensions/skills/src/utils/skills-cli-runner.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { access, stat } from "node:fs/promises";
import { access, mkdir, stat, writeFile } from "node:fs/promises";
import { constants } from "node:fs";
import { basename } from "node:path";
import { basename, join } from "node:path";
import { environment } from "@raycast/api";
import lockfile from "proper-lockfile";
import { getCustomNpxPath, shouldDisableSkillsCliTelemetry } from "../preferences";
import { execFileAsync } from "./exec-async";
import { getExecOptions } from "./exec-options";
Expand All @@ -12,6 +14,8 @@ let pendingCustomNpxValidation: { path: string; promise: Promise<void> } | null
let pendingSkillsCliRun: Promise<unknown> = Promise.resolve();
let bunxResolutionFailed = false;

const SKILLS_CLI_LOCK_TARGET = join(environment.supportPath, "skills-cli");

type ExecFailure = Error & {
code?: string | number;
stdout?: string | Buffer;
Expand Down Expand Up @@ -60,8 +64,18 @@ export interface RunSkillsCliOptions {
readOnly?: boolean;
}

export type SkillsCliRunner = (args: string[], options?: RunSkillsCliOptions) => Promise<string>;

export async function runSkillsCli(args: string[], options: RunSkillsCliOptions = {}): Promise<string> {
return enqueueSkillsCliRun(() => runSkillsCliCommand(args, options.readOnly ?? false));
return withSkillsCliLock((runLocked) => runLocked(args, options));
}

export async function withSkillsCliLock<T>(run: (runLocked: SkillsCliRunner) => Promise<T>): Promise<T> {
return enqueueSkillsCliRun(() =>
withCrossProcessSkillsCliLock(() =>
run((args, options = {}) => runSkillsCliCommand(args, options.readOnly ?? false)),
),
);
}

async function enqueueSkillsCliRun<T>(run: () => Promise<T>): Promise<T> {
Expand All @@ -70,6 +84,19 @@ async function enqueueSkillsCliRun<T>(run: () => Promise<T>): Promise<T> {
return runAfterPending;
}

async function withCrossProcessSkillsCliLock<T>(run: () => Promise<T>): Promise<T> {
await mkdir(environment.supportPath, { recursive: true });
await writeFile(SKILLS_CLI_LOCK_TARGET, "", { flag: "a" });
const release = await lockfile.lock(SKILLS_CLI_LOCK_TARGET, {
retries: { forever: true, factor: 1, minTimeout: 100, maxTimeout: 100, randomize: true },
});
try {
return await run();
} finally {
await release();
}
}

async function runSkillsCliCommand(args: string[], readOnly: boolean): Promise<string> {
const customNpxPath = getCustomNpxPath();
if (customNpxPath) {
Expand Down
5 changes: 3 additions & 2 deletions extensions/skills/src/utils/skills-cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
isInvalidCustomNpxPathError,
isNpxResolutionError,
runSkillsCli,
type SkillsCliRunner,
} from "./skills-cli-runner";

const home = homedir();
Expand Down Expand Up @@ -40,8 +41,8 @@ function parseSkillsListJson(stdout: string): InstalledSkill[] {
}));
}

export async function listInstalledSkills(): Promise<InstalledSkill[]> {
const stdout = await runSkillsCli(["list", "-g", "--json"], { readOnly: true });
export async function listInstalledSkills(runCli: SkillsCliRunner = runSkillsCli): Promise<InstalledSkill[]> {
const stdout = await runCli(["list", "-g", "--json"], { readOnly: true });
try {
return parseSkillsListJson(stdout);
} catch {
Expand Down