mcpField
R
raycast-extension

raycast-extension

github.com/johnlindquist开发26323 次安装45 周前更新
代码仓库

关于 raycast-extension

面向使用 TypeScript 和 `@raycast/api` 开发者的 Raycast 扩展指南,涵盖命令配置、界面模式、性能优化、测试与发布。

主要功能

  1. 01扩展项目结构与配置
  2. 02视图、无视图和菜单栏命令
  3. 03缓存与异步性能模式
  4. 04剪贴板和通知等 Raycast API
  5. 05开发、测试与发布流程

适用场景

  1. 01搭建新的 Raycast 扩展
  2. 02选择命令模式并实现界面
  3. 03减少阻塞操作并改善加载
  4. 04测试更改并提交扩展

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/johnlindquist/claude --skill raycast-extension

安装后在 Claude Code 中输入/raycast-extension确认已加载。

详细介绍

Raycast 扩展开发

快速开始

  1. 创建项目结构
  2. 编写包含扩展配置的 package.json
  3. 在 src/ 中实现命令
  4. 运行 npm install && npm run dev

项目结构

my-extension/
├── package.json          # Extension manifest + dependencies
├── tsconfig.json         # TypeScript config
├── .eslintrc.json        # ESLint config
├── raycast-env.d.ts      # Type definitions (auto-generated)
├── assets/
│   └── extension-icon.png  # 512x512 PNG icon
└── src/
    └── command-name.tsx    # Command implementation

package.json 模板

{
  "name": "extension-name",
  "title": "Extension Title",
  "description": "What this extension does",
  "icon": "extension-icon.png",
  "author": "author-name",
  "categories": ["Productivity", "Developer Tools"],
  "license": "MIT",
  "commands": [
    {
      "name": "command-name",
      "title": "Command Title",
      "description": "What this command does",
      "mode": "view",
      "keywords": ["keyword1", "keyword2"]
    }
  ],
  "dependencies": {
    "@raycast/api": "^1.83.1",
    "@raycast/utils": "^1.17.0"
  },
  "devDependencies": {
    "@raycast/eslint-config": "^1.0.11",
    "@types/node": "22.5.4",
    "@types/react": "18.3.3",
    "eslint": "^8.57.0",
    "prettier": "^3.3.3",
    "typescript": "^5.5.4"
  },
  "scripts": {
    "build": "ray build --skip-types -e dist -o dist",
    "dev": "ray develop",
    "fix-lint": "ray lint --fix",
    "lint": "ray lint"
  }
}

命令模式

模式使用场景
view使用 Detail、List、Form、Grid 显示界面
no-view仅执行后台任务、剪贴板操作和通知
menu-bar带下拉菜单的菜单栏图标

快捷键配置

在 package.json 的命令中添加:

"hotkey": {
  "modifiers": ["opt"],
  "key": "m"
}

修饰键:cmd、opt、ctrl、shift

注意:package.json 中的快捷键只是建议。用户可以在 Raycast 偏好设置 → 扩展中设置快捷键。

tsconfig.json

{
  "$schema": "https://json.schemastore.org/tsconfig",
  "compilerOptions": {
    "allowJs": true,
    "allowSyntheticDefaultImports": true,
    "esModuleInterop": true,
    "forceConsistentCasingInFileNames": true,
    "isolatedModules": true,
    "jsx": "react-jsx",
    "lib": ["ES2022"],
    "module": "ES2022",
    "moduleResolution": "bundler",
    "noEmit": true,
    "resolveJsonModule": true,
    "skipLibCheck": true,
    "strict": true,
    "target": "ES2022"
  },
  "include": ["src/**/*", "raycast-env.d.ts"]
}

.eslintrc.json

{
  "root": true,
  "extends": ["@raycast"]
}

命令模式示例

无界面命令(后台任务)
import { showHUD, Clipboard, showToast, Toast } from "@raycast/api";

export default async function Command() {
  const toast = await showToast({
    style: Toast.Style.Animated,
    title: "Working...",
  });

  try {
    // Do work
    const result = await doSomething();

    await Clipboard.copy(result);
    await showHUD("✅ Done!");
  } catch (error) {
    toast.style = Toast.Style.Failure;
    toast.title = "Failed";
    toast.message = error instanceof Error ? error.message : "Unknown error";
  }
}
界面命令(列表)
import { List, ActionPanel, Action } from "@raycast/api";

export default function Command() {
  return (
    <List>
      <List.Item
        title="Item"
        actions={
          <ActionPanel>
            <Action.CopyToClipboard content="text" />
          </ActionPanel>
        }
      />
    </List>
  );
}
界面命令(详情)
import { Detail } from "@raycast/api";

export default function Command() {
  const markdown = `# Hello World`;
  return <Detail markdown={markdown} />;
}

性能与缓存

即时加载模式(避免空白闪烁)

同步读取缓存 + 异步刷新,实现感知上的即时加载:

import { List, Cache } from "@raycast/api";
import { useCachedPromise, withCache } from "@raycast/utils";

const cache = new Cache();
const CACHE_KEY = "myData";

// Read cache synchronously at module load (before React renders)
function getInitialData(): MyData[] {
  const cached = cache.get(CACHE_KEY);
  if (cached) {
    try {
      return JSON.parse(cached);
    } catch {
      return [];
    }
  }
  return [];
}

// Expensive async operation wrapped with withCache (5 min TTL)
const fetchExpensiveData = withCache(
  async () => {
    // Your expensive operation here
    return await someSlowOperation();
  },
  { maxAge: 5 * 60 * 1000 }
);

async function fetchAllData(): Promise<MyData[]> {
  const data = await fetchExpensiveData();
  // Update cache for next launch
  cache.set(CACHE_KEY, JSON.stringify(data));
  return data;
}

export default function Command() {
  const { data, isLoading } = useCachedPromise(fetchAllData, [], {
    initialData: getInitialData(), // Sync read - instant render!
    keepPreviousData: true,
  });

  return (
    <List isLoading={isLoading && !data?.length}>
      {data?.map(item => <List.Item key={item.id} title={item.name} />)}
    </List>
  );
}
关键缓存工具
工具用途
Cache持久化磁盘缓存,同步读写
withCache(fn, {maxAge})为异步函数封装 TTL 缓存
useCachedPromise过期后重新验证模式
LocalStorage异步键值存储
避免 CLS(内容布局偏移)

在一个异步函数中加载所有数据:

// BAD - causes layout shift
const [customData, setCustomData] = useState([]);
useEffect(() => {
  loadCustomData().then(setCustomData); // Second render!
}, []);

// GOOD - single fetch, no shift
async function fetchAllData() {
  const [dataA, dataB] = await Promise.all([
    fetchDataA(),
    fetchDataB(),
  ]);
  return combineData(dataA, dataB);
}
非阻塞操作(防止界面冻结)

“微小延迟”的根本原因:重新验证期间,同步操作(execSync、statSync、readdirSync)会阻塞事件循环,即使已显示缓存数据,也会导致界面冻结。

// BAD - blocks event loop, UI freezes during revalidation
import { execSync } from "child_process";
import { statSync, readdirSync, copyFileSync } from "fs";

function fetchData() {
  copyFileSync(src, dest);                    // Blocks!
  const result = execSync("sqlite3 query");   // Blocks!
  const entries = readdirSync(dir);           // Blocks!
  for (const entry of entries) {
    statSync(join(dir, entry));               // Blocks N times!
  }
}

// GOOD - fully async, UI renders cached data while refreshing
import { exec } from "child_process";
import { promisify } from "util";
import { stat, readdir, copyFile, access } from "fs/promises";

const execAsync = promisify(exec);

async function fetchData() {
  await copyFile(src, dest);                         // Non-blocking
  const { stdout } = await execAsync("sqlite3...");  // Non-blocking

  // Use withFileTypes to avoid extra stat calls
  const entries = await readdir(dir, { withFileTypes: true });
  const results = entries
    .filter(e => e.isDirectory())  // No stat needed!
    .map(e => ({ path: join(dir, e.name), name: e.name }));
}

关键优化:

  1. 用 promisify(exec) 替换 exec 来执行 Shell 命令
  2. 用 fs/promises 中的 access() 替换 existsSync
  3. 用 readdir(dir, { withFileTypes: true }) 替换 readdirSync + statSync 循环
  4. 使用 Promise.all 并行运行所有路径验证
  5. 使用 SQLite URI 模式直接只读访问(无需复制文件)
SQLite 直接访问(跳过文件复制)

读取其他应用(如 Zed、VS Code 等)的 SQLite 数据库时,避免复制数据库文件。使用 URI 模式直接只读访问:

// BAD - copies entire database file (slow, blocks)
import { copyFileSync, unlinkSync } from "fs";

const tempDb = `/tmp/copy-${Date.now()}.sqlite`;
copyFileSync(originalDb, tempDb);           // Expensive!
execSync(`sqlite3 "${tempDb}" "SELECT..."`);
unlinkSync(tempDb);                         // Cleanup

// GOOD - direct read-only access via URI mode
const uri = `file:${originalDb}?mode=ro&immutable=1`;
const { stdout } = await execAsync(`sqlite3 "${uri}" "SELECT..."`);

URI 参数:

  • mode=ro - 只读模式,不获取写锁
  • immutable=1 - 跳过 WAL/锁检查,将文件视为不可变

这样可以完全省去文件复制,大幅节省 I/O 时间。

execFile 与 exec(绕过 Shell)

exec 会启动一个 Shell(开销约 20ms),而 execFile 会直接调用二进制文件(开销约 4ms):

// BAD - spawns shell, parses command string
import { exec } from "child_process";
const execAsync = promisify(exec);
await execAsync(`sqlite3 -separator '|||' "${db}" "${query}"`);

// GOOD - direct binary execution, ~16ms faster
import { execFile } from "child_process";
const execFileAsync = promisify(execFile);
await execFileAsync("sqlite3", ["-separator", "|||", db, query]);
Sidecar 模式(真正的后台预加载)

如需实现真正即时的冷启动,请使用后台工作进程,在用户打开扩展前预热缓存。

问题:view 命令无法使用 interval(后台调度)。只有 no-view 和 menu-bar 模式支持该功能。

**解决方案:**创建两个共享同一缓存的命令:

// package.json
{
  "commands": [
    {
      "name": "main",
      "title": "My Extension",
      "mode": "view"
    },
    {
      "name": "background-sync",
      "title": "Background Sync",
      "mode": "no-view",
      "interval": "15m"
    }
  ]
}
// shared-cache.ts - both commands import this
import { Cache } from "@raycast/api";
export const sharedCache = new Cache(); // Shared across extension

// background-sync.tsx (no-view worker)
import { sharedCache } from "./shared-cache";
export default async function Command() {
  const data = await fetchExpensiveData();
  sharedCache.set("projects", JSON.stringify(data));
}

// main.tsx (view command)
import { sharedCache } from "./shared-cache";
function getInitialData() {
  const cached = sharedCache.get("projects");
  return cached ? JSON.parse(cached) : [];
}
export default function Command() {
  const { data } = useCachedPromise(fetchData, [], {
    initialData: getInitialData(), // Instant from pre-warmed cache!
  });
}

要点:

  • 工作进程按间隔静默运行,用户不会看到它
  • 两个命令共享同一个 Cache(作用域为扩展,而非命令)
  • 界面命令从预热的缓存中同步读取
  • 使用 15m 到 1h 的间隔,避免电池消耗或速率限制问题
大型数据集:使用 useSQL,而非 JSON 缓存

对于超过 1,000 个条目的数据集,使用 SQLite 而非 JSON 缓存,以实现即时筛选:

// BAD - loads entire 10MB JSON into memory to filter
const allProjects = JSON.parse(cache.get("projects"));
const filtered = allProjects.filter(p => p.name.includes(query));

// GOOD - SQLite queries only matching rows
import { useSQL } from "@raycast/utils";
const { data } = useSQL(dbPath, `SELECT * FROM projects WHERE name LIKE ?`, [`%${query}%`]);
乐观 UI(即时操作)

对于写入操作,在 API 确认前立即更新界面:

const { mutate } = useCachedPromise(fetchItems);

async function deleteItem(id: string) {
  await mutate(deleteItemAPI(id), {
    optimisticUpdate: (current) => current.filter(i => i.id !== id),
    rollbackOnError: true, // Revert if API fails
  });
}

用户可以即时看到反馈;如果失败,则会自动回滚。

// BAD - sequential stat calls
const entries = readdirSync(dir);
for (const entry of entries) {
  const s = statSync(join(dir, entry));  // N blocking calls
}

// GOOD - parallel async checks
const checkPath = async (p: string) => {
  try {
    const s = await stat(p);
    return s.isDirectory() ? p : null;
  } catch { return null; }
};

const results = await Promise.all(paths.map(checkPath));

常用 API

剪贴板
import { Clipboard } from "@raycast/api";

await Clipboard.copy("text");
await Clipboard.paste("text");
const text = await Clipboard.readText();
通知
import { showHUD, showToast, Toast } from "@raycast/api";

// Quick notification (disappears)
await showHUD("Done!");

// Toast with progress
const toast = await showToast({
  style: Toast.Style.Animated,
  title: "Loading...",
});
toast.style = Toast.Style.Success;
toast.title = "Complete";
AppleScript(macOS 集成)
import { runAppleScript } from "@raycast/utils";

// Get Chrome active tab URL
const url = await runAppleScript(`
  tell application "Google Chrome"
    return URL of active tab of front window
  end tell
`);

// Get Safari URL
const safariUrl = await runAppleScript(`
  tell application "Safari"
    return URL of current tab of front window
  end tell
`);

// Get frontmost app
const app = await runAppleScript(`
  tell application "System Events"
    return name of first application process whose frontmost is true
  end tell
`);
获取数据
// Native fetch works
const response = await fetch("https://api.example.com/data");
const data = await response.json();
偏好设置

在 package.json 中:

"preferences": [
  {
    "name": "apiKey",
    "type": "password",
    "required": true,
    "title": "API Key",
    "description": "Your API key"
  }
]

在代码中:

import { getPreferenceValues } from "@raycast/api";

interface Preferences {
  apiKey: string;
}

const { apiKey } = getPreferenceValues<Preferences>();

创建扩展图标

使用 ImageMagick:

convert -size 512x512 xc:'#6366F1' -fill white -gravity center \
  -font Helvetica-Bold -pointsize 280 -annotate +0+20 'M' \
  assets/extension-icon.png

开发工作流

# Install dependencies
npm install

# Start dev server (hot reload)
npm run dev

# Lint and fix
npm run fix-lint

# Build for production
npm run build

Raycast Deeplinks

通过 URL 方案以编程方式触发 Raycast 命令:

# Reload all extensions
open "raycast://extensions/raycast/raycast/reload-extensions"

# Open Raycast
open "raycast://focus"

# Run any extension command
open "raycast://extensions/{author}/{extension}/{command}"
构建后自动重新加载

在 package.json 中添加脚本:

"build": "ray build --skip-types -e dist -o dist && open raycast://extensions/raycast/raycast/reload-extensions"

或者创建重新加载脚本:

#!/bin/bash
npm run build && open "raycast://extensions/raycast/raycast/reload-extensions"

在 Raycast 中测试

  1. 运行 npm run dev(提供热重载)
  2. 打开 Raycast
  3. 搜索你的命令名称
  4. 按 Enter 运行

如果开发服务器未运行,请使用 deeplink 在更改后重新加载:

npm run build && open "raycast://extensions/raycast/raycast/reload-extensions"

发布

npm run publish

提交到 Raycast Store 以供审核。

评论

支持 Markdown