diff --git a/README.md b/README.md
index 183e02d..15d7ae0 100644
--- a/README.md
+++ b/README.md
@@ -12,10 +12,13 @@ You'll find below a summary of all the rules included in our ESLint plugin.
🔧 Automatically fixable by the [`--fix` CLI option](https://eslint.org/docs/user-guide/command-line-interface#--fix).
-| Name | Description | 🔧 |
-| :------------------------------------------------------- | :---------------------------------- | :- |
-| [prefer-ellipsis](docs/rules/prefer-ellipsis.md) | Prefer Ellipsis Character | 🔧 |
-| [prefer-placeholders](docs/rules/prefer-placeholders.md) | Prefer Placeholders for Text Fields | |
-| [prefer-title-case](docs/rules/prefer-title-case.md) | Prefer Title Case | 🔧 |
+| Name | Description | 🔧 |
+| :----------------------------------------------------------------------------- | :---------------------------------------------------------------------------------------------- | :- |
+| [no-ambiguous-platform-shortcut](docs/rules/no-ambiguous-platform-shortcut.md) | Warn when a shortcut is ambiguous in cross-platform extensions. | |
+| [no-reserved-shortcut](docs/rules/no-reserved-shortcut.md) | Warn when a shortcut prop defines a reserved shortcut that Raycast uses. | |
+| [prefer-common-shortcut](docs/rules/prefer-common-shortcut.md) | Warn when a shortcut matches a common one; prefer Keyboard.Shortcut.Common.* from @raycast/api. | 🔧 |
+| [prefer-ellipsis](docs/rules/prefer-ellipsis.md) | Prefer Ellipsis Character | 🔧 |
+| [prefer-placeholders](docs/rules/prefer-placeholders.md) | Prefer Placeholders for Text Fields | |
+| [prefer-title-case](docs/rules/prefer-title-case.md) | Prefer Title Case | 🔧 |
diff --git a/docs/rules/no-ambiguous-platform-shortcut.md b/docs/rules/no-ambiguous-platform-shortcut.md
new file mode 100644
index 0000000..7ad5377
--- /dev/null
+++ b/docs/rules/no-ambiguous-platform-shortcut.md
@@ -0,0 +1,49 @@
+# Warn when a shortcut is ambiguous in cross-platform extensions (`@raycast/no-ambiguous-platform-shortcut`)
+
+
+
+Warns when a single-form shortcut literal (one modifiers/key object) uses only `cmd` or only `ctrl` in an extension that targets multiple platforms. In multi-platform extensions, you should either provide platform-specific shortcuts (with `macOS`/`Windows` sections) or include both modifiers to ensure parity.
+
+## Rule Details
+
+This rule checks JSX attributes named `shortcut`. It only inspects literal objects of the simple form:
+
+```tsx
+
+```
+
+Platform-specific shortcut objects (those with `macOS`/`Windows`) and dynamic expressions are ignored.
+
+The rule first locates the closest `package.json` to the file being linted. If the package declares a `platforms` array with more than one value, the rule activates and warns about ambiguous shortcuts.
+
+### Examples of incorrect code
+
+```tsx
+
+```
+
+```tsx
+
+```
+
+### Examples of correct code
+
+```tsx
+
+```
+
+```tsx
+
+```
+
+```tsx
+// Single-platform extension ⇒ rule does not apply
+
+```
+
+> ℹ️ The rule only warns; it does not provide an autofix. Choose platform-specific shortcuts or include both modifiers manually.
diff --git a/docs/rules/no-reserved-shortcut.md b/docs/rules/no-reserved-shortcut.md
new file mode 100644
index 0000000..d1f7e3f
--- /dev/null
+++ b/docs/rules/no-reserved-shortcut.md
@@ -0,0 +1,60 @@
+# Warn when a shortcut prop defines a reserved shortcut that Raycast uses (`@raycast/no-reserved-shortcut`)
+
+
+
+Warns when you define a literal shortcut that conflicts with one of Raycast's reserved shortcuts. Reserved shortcuts are used internally by the Raycast UI and reusing them in extensions may create confusing experiences.
+
+## Rule Details
+
+This rule inspects JSX `shortcut` attributes with a direct object literal of the simple form:
+
+```tsx
+
+```
+
+Platform form objects (with `macOS` / `Windows`) and dynamically built shortcuts are ignored.
+
+### Examples of incorrect code
+
+```tsx
+
+```
+
+```tsx
+
+```
+
+### Examples of correct code
+
+```tsx
+
+```
+
+```tsx
+
+```
+
+## Reserved Shortcuts Checked
+
+- CloseWindow: cmd + w
+- Delete: delete
+- DeleteForward: deleteForward
+- DeleteLineBackward: cmd + delete
+- DeleteWordBackward: opt + delete
+- GoBack: escape
+- OpenActionPanel: cmd + k
+- OpenPreferences: cmd + ,
+- OpenSearchBarDropdown: cmd + p
+- OpenSearchBarLink: shift + cmd + /
+- PrimaryAction: enter
+- Quit: cmd + q
+- ReturnToRoot: cmd + escape
+- SecondaryAction: cmd + enter
+- SelectAll: cmd + a
+
+Note: The rule does not attempt an autofix; choose an alternate shortcut instead.
diff --git a/docs/rules/prefer-common-shortcut.md b/docs/rules/prefer-common-shortcut.md
new file mode 100644
index 0000000..18e4fa1
--- /dev/null
+++ b/docs/rules/prefer-common-shortcut.md
@@ -0,0 +1,52 @@
+# Warn when a shortcut matches a common one; prefer Keyboard.Shortcut.Common.* from @raycast/api (`@raycast/prefer-common-shortcut`)
+
+🔧 This rule is automatically fixable by the [`--fix` CLI option](https://eslint.org/docs/latest/user-guide/command-line-interface#--fix).
+
+
+
+When a React component uses a `shortcut` prop with a literal object that matches a
+well-known shortcut, prefer using `Keyboard.Shortcut.Common.*` from `@raycast/api`.
+
+This keeps shortcuts consistent across platforms and makes intent explicit.
+
+## Rule Details
+
+This rule inspects JSX attributes named `shortcut` and looks for object literals in either of these forms:
+
+- Single form: `{ modifiers: ["cmd"], key: "s" }`
+- Platform form: `{ macOS: { modifiers: ["cmd"], key: "s" }, Windows: { modifiers: ["ctrl"], key: "s" } }`
+
+If the value equals a known common shortcut, the rule suggests replacing it with the corresponding
+`Keyboard.Shortcut.Common.Name` and will also add `Keyboard` to your `@raycast/api` import or create
+one if missing.
+
+### Examples
+
+Incorrect code:
+
+```tsx
+
+```
+
+```tsx
+
+```
+
+Correct code:
+
+```tsx
+import { Keyboard } from "@raycast/api";
+
+;
+```
+
+```tsx
+
+```
+
+Note that the rule only flags "single" (non-platform) object literals when the common shortcut is exactly the same on macOS and Windows. For platform-specific differences, use the `platform` object form in your code or the corresponding `Keyboard.Shortcut.Common.*` reference.
diff --git a/lib/index.ts b/lib/index.ts
index c0930a0..30ffe85 100644
--- a/lib/index.ts
+++ b/lib/index.ts
@@ -4,6 +4,9 @@ import path from "path";
import preferEllipis from "./rules/prefer-ellipsis";
import preferPlaceholders from "./rules/prefer-placeholders";
import preferTitleCase from "./rules/prefer-title-case";
+import preferCommonShortcut from "./rules/prefer-common-shortcut";
+import noReservedShortcut from "./rules/no-reserved-shortcut";
+import noAmbiguousPlatformShortcut from "./rules/no-ambiguous-platform-shortcut";
const pkg = JSON.parse(
fs.readFileSync(path.join(__dirname, "..", "package.json"), "utf8")
@@ -19,6 +22,9 @@ const plugin = {
"prefer-ellipsis": preferEllipis,
"prefer-title-case": preferTitleCase,
"prefer-placeholders": preferPlaceholders,
+ "prefer-common-shortcut": preferCommonShortcut,
+ "no-reserved-shortcut": noReservedShortcut,
+ "no-ambiguous-platform-shortcut": noAmbiguousPlatformShortcut,
},
};
@@ -31,6 +37,9 @@ Object.assign(plugin.configs, {
rules: {
"@raycast/prefer-ellipsis": "warn",
"@raycast/prefer-title-case": "warn",
+ "@raycast/prefer-common-shortcut": "warn",
+ "@raycast/no-reserved-shortcut": "warn",
+ "@raycast/no-ambiguous-platform-shortcut": "warn",
},
},
],
diff --git a/lib/rules/no-ambiguous-platform-shortcut.ts b/lib/rules/no-ambiguous-platform-shortcut.ts
new file mode 100644
index 0000000..87b1eef
--- /dev/null
+++ b/lib/rules/no-ambiguous-platform-shortcut.ts
@@ -0,0 +1,140 @@
+import fs from "fs";
+import path from "path";
+
+import { AST_NODE_TYPES, TSESLint, TSESTree } from "@typescript-eslint/utils";
+
+import { createRule } from "../utils";
+
+type SimpleShortcut = { modifiers: string[]; key: string };
+
+const packagePlatformsCache = new Map();
+
+function hasMultiPlatformConfig(filename: string | undefined): boolean {
+ if (!filename || filename.startsWith("<")) {
+ return false;
+ }
+
+ let dir = path.dirname(filename);
+ while (true) {
+ const pkgPath = path.join(dir, "package.json");
+ if (packagePlatformsCache.has(pkgPath)) {
+ return packagePlatformsCache.get(pkgPath)!;
+ }
+
+ if (fs.existsSync(pkgPath)) {
+ try {
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
+ const platforms = pkg?.platforms;
+ const multi = Array.isArray(platforms) && platforms.length > 1;
+ packagePlatformsCache.set(pkgPath, multi);
+ return multi;
+ } catch {
+ packagePlatformsCache.set(pkgPath, false);
+ return false;
+ }
+ }
+
+ const parent = path.dirname(dir);
+ if (parent === dir) {
+ break;
+ }
+ dir = parent;
+ }
+
+ return false;
+}
+
+function parseSimpleShortcut(
+ node: TSESTree.ObjectExpression
+): SimpleShortcut | null {
+ const props = new Map();
+ for (const prop of node.properties) {
+ if (
+ prop.type !== AST_NODE_TYPES.Property ||
+ prop.key.type !== AST_NODE_TYPES.Identifier
+ ) {
+ return null;
+ }
+ if (prop.key.name !== "modifiers" && prop.key.name !== "key") {
+ return null;
+ }
+ props.set(prop.key.name, prop);
+ }
+
+ if (props.size !== 2) {
+ return null;
+ }
+
+ const modifiersProp = props.get("modifiers");
+ const keyProp = props.get("key");
+ if (!modifiersProp || !keyProp) return null;
+ if (
+ modifiersProp.value.type !== AST_NODE_TYPES.ArrayExpression ||
+ keyProp.value.type !== AST_NODE_TYPES.Literal ||
+ typeof keyProp.value.value !== "string"
+ ) {
+ return null;
+ }
+
+ const modifiers: string[] = [];
+ for (const el of modifiersProp.value.elements) {
+ if (!el) continue;
+ if (el.type !== AST_NODE_TYPES.Literal || typeof el.value !== "string") {
+ return null;
+ }
+ modifiers.push(el.value);
+ }
+
+ return { modifiers, key: keyProp.value.value };
+}
+
+function isAmbiguous(modifiers: string[]) {
+ const hasCmd = modifiers.includes("cmd");
+ const hasCtrl = modifiers.includes("ctrl");
+ return (hasCmd || hasCtrl) && !(hasCmd && hasCtrl);
+}
+
+export default createRule({
+ name: "no-ambiguous-platform-shortcut",
+ meta: {
+ type: "problem",
+ docs: {
+ description:
+ "Warn when a shortcut is ambiguous in cross-platform extensions.",
+ },
+ schema: [],
+ messages: {
+ ambiguous:
+ "This shortcut is ambiguous across platforms. Provide platform-specific shortcuts.",
+ },
+ },
+ defaultOptions: [],
+ create(context) {
+ const hasMultiPlatform = hasMultiPlatformConfig(context.filename);
+
+ if (!hasMultiPlatform) {
+ return {};
+ }
+
+ return {
+ JSXAttribute(node) {
+ if (
+ node.name.type === AST_NODE_TYPES.JSXIdentifier &&
+ node.name.name === "shortcut" &&
+ node.value &&
+ node.value.type === AST_NODE_TYPES.JSXExpressionContainer &&
+ node.value.expression.type === AST_NODE_TYPES.ObjectExpression
+ ) {
+ const simple = parseSimpleShortcut(node.value.expression);
+ if (!simple) return;
+ if (!isAmbiguous(simple.modifiers)) return;
+
+ context.report({
+ node: node.value,
+ messageId: "ambiguous",
+ });
+ }
+ },
+ };
+ },
+});
diff --git a/lib/rules/no-reserved-shortcut.ts b/lib/rules/no-reserved-shortcut.ts
new file mode 100644
index 0000000..465912e
--- /dev/null
+++ b/lib/rules/no-reserved-shortcut.ts
@@ -0,0 +1,123 @@
+import { AST_NODE_TYPES, TSESTree } from "@typescript-eslint/utils";
+import { createRule } from "../utils";
+
+type SimpleShortcut = { modifiers: string[]; key: string };
+
+interface ReservedShortcutDefinition extends SimpleShortcut {
+ name: string;
+}
+
+// Source list copied from @raycast/api Keyboard.Shortcut.Reserved
+const RESERVED: ReservedShortcutDefinition[] = [
+ { name: "CloseWindow", modifiers: ["cmd"], key: "w" },
+ { name: "Delete", modifiers: [], key: "delete" },
+ { name: "DeleteForward", modifiers: [], key: "deleteForward" },
+ { name: "DeleteLineBackward", modifiers: ["cmd"], key: "delete" },
+ { name: "DeleteWordBackward", modifiers: ["opt"], key: "delete" },
+ { name: "GoBack", modifiers: [], key: "escape" },
+ { name: "OpenActionPanel", modifiers: ["cmd"], key: "k" },
+ { name: "OpenPreferences", modifiers: ["cmd"], key: "," },
+ { name: "OpenSearchBarDropdown", modifiers: ["cmd"], key: "p" },
+ { name: "OpenSearchBarLink", modifiers: ["shift", "cmd"], key: "/" },
+ { name: "PrimaryAction", modifiers: [], key: "enter" },
+ { name: "Quit", modifiers: ["cmd"], key: "q" },
+ { name: "ReturnToRoot", modifiers: ["cmd"], key: "escape" },
+ { name: "SecondaryAction", modifiers: ["cmd"], key: "enter" },
+ { name: "SelectAll", modifiers: ["cmd"], key: "a" },
+];
+
+function parseSimpleShortcut(
+ node: TSESTree.ObjectExpression
+): SimpleShortcut | null {
+ const props = new Map();
+ for (const p of node.properties) {
+ if (
+ p.type === AST_NODE_TYPES.Property &&
+ p.key.type === AST_NODE_TYPES.Identifier
+ ) {
+ const name = p.key.name;
+ if (name !== "modifiers" && name !== "key") {
+ // Extra property -> not a pure simple shortcut literal
+ return null;
+ }
+ props.set(name, p);
+ } else {
+ // Spread or other patterns => ignore
+ return null;
+ }
+ }
+ const modifiersProp = props.get("modifiers");
+ const keyProp = props.get("key");
+ if (!keyProp || !modifiersProp) return null;
+ // Must have exactly two properties
+ if (props.size !== 2) return null;
+ if (
+ modifiersProp.value.type !== AST_NODE_TYPES.ArrayExpression ||
+ keyProp.value.type !== AST_NODE_TYPES.Literal ||
+ typeof keyProp.value.value !== "string"
+ ) {
+ return null;
+ }
+ const modifiers: string[] = [];
+ for (const el of modifiersProp.value.elements) {
+ if (!el) continue;
+ if (el.type !== AST_NODE_TYPES.Literal || typeof el.value !== "string")
+ return null;
+ modifiers.push(el.value);
+ }
+ return { modifiers, key: keyProp.value.value };
+}
+
+function matchesReserved(
+ sc: SimpleShortcut
+): ReservedShortcutDefinition | null {
+ for (const r of RESERVED) {
+ if (r.key !== sc.key) continue;
+ if (r.modifiers.length !== sc.modifiers.length) continue;
+ const a = [...r.modifiers].sort();
+ const b = [...sc.modifiers].sort();
+ if (a.every((v, i) => v === b[i])) return r;
+ }
+ return null;
+}
+
+export default createRule({
+ name: "no-reserved-shortcut",
+ meta: {
+ type: "suggestion",
+ docs: {
+ description:
+ "Warn when a shortcut prop defines a reserved shortcut that Raycast uses.",
+ },
+ schema: [],
+ messages: {
+ reserved:
+ "Shortcut matches reserved shortcut '{{name}}' and will be ignored by Raycast.",
+ },
+ },
+ defaultOptions: [],
+ create(context) {
+ return {
+ JSXAttribute(node) {
+ if (
+ node.name.type === AST_NODE_TYPES.JSXIdentifier &&
+ node.name.name === "shortcut" &&
+ node.value &&
+ node.value.type === AST_NODE_TYPES.JSXExpressionContainer &&
+ node.value.expression.type === AST_NODE_TYPES.ObjectExpression
+ ) {
+ const obj = node.value.expression;
+ const simple = parseSimpleShortcut(obj);
+ if (!simple) return; // Ignore platform form or dynamic shapes
+ const match = matchesReserved(simple);
+ if (!match) return;
+ context.report({
+ node: node.value,
+ messageId: "reserved",
+ data: { name: match.name },
+ });
+ }
+ },
+ };
+ },
+});
diff --git a/lib/rules/prefer-common-shortcut.ts b/lib/rules/prefer-common-shortcut.ts
new file mode 100644
index 0000000..9988468
--- /dev/null
+++ b/lib/rules/prefer-common-shortcut.ts
@@ -0,0 +1,323 @@
+import { AST_NODE_TYPES, TSESLint, TSESTree } from "@typescript-eslint/utils";
+
+import { createRule } from "../utils";
+import { RuleFix } from "@typescript-eslint/utils/dist/ts-eslint";
+
+type SimpleShortcut = { modifiers: string[]; key: string };
+
+type CommonShortcut = {
+ name: string;
+ macOS: SimpleShortcut;
+ Windows: SimpleShortcut;
+};
+
+// Source of truth copied from @raycast/api Keyboard.Shortcut.Common
+const COMMON_SHORTCUTS: CommonShortcut[] = [
+ {
+ name: "Copy",
+ macOS: { modifiers: ["cmd", "shift"], key: "c" },
+ Windows: { modifiers: ["ctrl", "shift"], key: "c" },
+ },
+ {
+ name: "CopyDeeplink",
+ macOS: { modifiers: ["cmd", "shift"], key: "c" },
+ Windows: { modifiers: ["ctrl", "shift"], key: "c" },
+ },
+ {
+ name: "CopyName",
+ macOS: { modifiers: ["cmd", "opt"], key: "c" },
+ Windows: { modifiers: ["ctrl", "alt"], key: "c" },
+ },
+ {
+ name: "CopyPath",
+ macOS: { modifiers: ["cmd", "ctrl"], key: "c" },
+ Windows: { modifiers: ["alt", "shift"], key: "c" },
+ },
+ {
+ name: "Save",
+ macOS: { modifiers: ["cmd"], key: "s" },
+ Windows: { modifiers: ["ctrl"], key: "s" },
+ },
+ {
+ name: "Duplicate",
+ macOS: { modifiers: ["cmd", "shift"], key: "s" },
+ Windows: { modifiers: ["ctrl", "shift"], key: "s" },
+ },
+ {
+ name: "Edit",
+ macOS: { modifiers: ["cmd"], key: "e" },
+ Windows: { modifiers: ["ctrl"], key: "e" },
+ },
+ {
+ name: "MoveDown",
+ macOS: { modifiers: ["cmd", "shift"], key: "arrowDown" },
+ Windows: { modifiers: ["ctrl", "shift"], key: "arrowDown" },
+ },
+ {
+ name: "MoveUp",
+ macOS: { modifiers: ["cmd", "shift"], key: "arrowUp" },
+ Windows: { modifiers: ["ctrl", "shift"], key: "arrowUp" },
+ },
+ {
+ name: "New",
+ macOS: { modifiers: ["cmd"], key: "n" },
+ Windows: { modifiers: ["ctrl"], key: "n" },
+ },
+ {
+ name: "Open",
+ macOS: { modifiers: ["cmd"], key: "o" },
+ Windows: { modifiers: ["ctrl"], key: "o" },
+ },
+ {
+ name: "OpenWith",
+ macOS: { modifiers: ["cmd", "shift"], key: "o" },
+ Windows: { modifiers: ["ctrl", "shift"], key: "o" },
+ },
+ {
+ name: "Pin",
+ macOS: { modifiers: ["cmd"], key: "." },
+ Windows: { modifiers: ["ctrl"], key: "." },
+ },
+ {
+ name: "Refresh",
+ macOS: { modifiers: ["cmd"], key: "r" },
+ Windows: { modifiers: ["ctrl"], key: "r" },
+ },
+ {
+ name: "Remove",
+ macOS: { modifiers: ["ctrl"], key: "d" },
+ Windows: { modifiers: ["ctrl"], key: "d" },
+ },
+ {
+ name: "RemoveAll",
+ macOS: { modifiers: ["ctrl", "shift"], key: "d" },
+ Windows: { modifiers: ["ctrl", "shift"], key: "d" },
+ },
+ {
+ name: "ToggleQuickLook",
+ macOS: { modifiers: ["cmd"], key: "y" },
+ Windows: { modifiers: ["ctrl"], key: "y" },
+ },
+];
+
+function eq(a: string[], b: string[]) {
+ if (a.length !== b.length) return false;
+ const as = [...a].sort();
+ const bs = [...b].sort();
+ return as.every((v, i) => v === bs[i]);
+}
+
+function normalizeSimpleShortcut(
+ node: TSESTree.ObjectExpression
+): SimpleShortcut | null {
+ const props = new Map();
+ for (const p of node.properties) {
+ if (
+ p.type === AST_NODE_TYPES.Property &&
+ p.key.type === AST_NODE_TYPES.Identifier
+ ) {
+ props.set(p.key.name, p);
+ }
+ }
+
+ const modifiersProp = props.get("modifiers");
+ const keyProp = props.get("key");
+ if (!modifiersProp || !keyProp) return null;
+
+ // modifiers: ["cmd", "shift"]
+ if (
+ modifiersProp.value.type !== AST_NODE_TYPES.ArrayExpression ||
+ keyProp.value.type !== AST_NODE_TYPES.Literal ||
+ typeof keyProp.value.value !== "string"
+ ) {
+ return null;
+ }
+
+ const modifiers: string[] = [];
+ for (const el of modifiersProp.value.elements) {
+ if (!el) continue;
+ if (el.type !== AST_NODE_TYPES.Literal || typeof el.value !== "string")
+ return null;
+ modifiers.push(el.value);
+ }
+
+ return { modifiers, key: keyProp.value.value };
+}
+
+function parseShortcutValue(
+ node: TSESTree.ObjectExpression
+):
+ | { kind: "single"; value: SimpleShortcut }
+ | { kind: "platform"; macOS: SimpleShortcut; Windows: SimpleShortcut }
+ | null {
+ // Detect platform-specific keys (Windows/windows and macOS)
+ const map = new Map();
+ for (const p of node.properties) {
+ if (
+ p.type === AST_NODE_TYPES.Property &&
+ (p.key.type === AST_NODE_TYPES.Identifier ||
+ p.key.type === AST_NODE_TYPES.Literal)
+ ) {
+ const k =
+ p.key.type === AST_NODE_TYPES.Identifier
+ ? p.key.name
+ : String(p.key.value);
+ if (p.value.type === AST_NODE_TYPES.ObjectExpression) {
+ map.set(k, p.value);
+ }
+ }
+ }
+
+ const mac = map.get("macOS");
+ const win = map.get("Windows") ?? map.get("windows");
+ if (mac && win) {
+ const macS = normalizeSimpleShortcut(mac);
+ const winS = normalizeSimpleShortcut(win);
+ if (macS && winS) return { kind: "platform", macOS: macS, Windows: winS };
+ return null;
+ }
+
+ // Otherwise, try simple form
+ const simple = normalizeSimpleShortcut(node);
+ if (simple) return { kind: "single", value: simple };
+ return null;
+}
+
+function findMatchingCommon(
+ sc:
+ | { kind: "single"; value: SimpleShortcut }
+ | { kind: "platform"; macOS: SimpleShortcut; Windows: SimpleShortcut }
+): CommonShortcut | null {
+ if (sc.kind === "platform") {
+ for (const c of COMMON_SHORTCUTS) {
+ if (
+ c.macOS.key === sc.macOS.key &&
+ c.Windows.key === sc.Windows.key &&
+ eq(c.macOS.modifiers, sc.macOS.modifiers) &&
+ eq(c.Windows.modifiers, sc.Windows.modifiers)
+ ) {
+ return c;
+ }
+ }
+ return null;
+ }
+
+ // For single-form, match either the macOS or Windows definition of a common shortcut
+ for (const c of COMMON_SHORTCUTS) {
+ const macMatch =
+ c.macOS.key === sc.value.key && eq(c.macOS.modifiers, sc.value.modifiers);
+ const winMatch =
+ c.Windows.key === sc.value.key &&
+ eq(c.Windows.modifiers, sc.value.modifiers);
+ if (macMatch || winMatch) return c;
+ }
+ return null;
+}
+
+function ensureKeyboardImportFix(
+ fixer: TSESLint.RuleFixer,
+ program: TSESTree.Program
+) {
+ // Find an import from "@raycast/api"
+ const imports = program.body.filter(
+ (n): n is TSESTree.ImportDeclaration =>
+ n.type === AST_NODE_TYPES.ImportDeclaration
+ );
+ const apiImport = imports.find((i) => i.source.value === "@raycast/api");
+
+ if (!apiImport) {
+ // Insert a new import at top
+ return fixer.insertTextBefore(
+ (program.body[0] as TSESTree.Node) ?? program,
+ `import { Keyboard } from "@raycast/api";\n`
+ );
+ }
+
+ // If already has Keyboard named import, do nothing
+ const hasKeyboard = apiImport.specifiers.some(
+ (s) =>
+ s.type === AST_NODE_TYPES.ImportSpecifier &&
+ s.imported.type === AST_NODE_TYPES.Identifier &&
+ s.imported.name === "Keyboard"
+ );
+ if (hasKeyboard) return null;
+
+ // If it's a named import, add Keyboard
+ const lastSpecifier = apiImport.specifiers[apiImport.specifiers.length - 1];
+ if (lastSpecifier && lastSpecifier.type === AST_NODE_TYPES.ImportSpecifier) {
+ return fixer.insertTextAfter(lastSpecifier, `, Keyboard`);
+ }
+
+ // If it's a default or namespace import, add a named import group
+ // Transform: import api from "@raycast/api"; -> import api, { Keyboard } from "@raycast/api";
+ // Insert after the default/namespace specifier if present
+ const spec = apiImport.specifiers[0];
+ if (spec) {
+ return fixer.insertTextAfter(spec, `, { Keyboard }`);
+ }
+ // No specifiers (e.g., `import "@raycast/api";`), add a new import above
+ return fixer.insertTextBefore(
+ apiImport,
+ `import { Keyboard } from "@raycast/api";\n`
+ );
+}
+
+export default createRule({
+ name: "prefer-common-shortcut",
+ meta: {
+ type: "suggestion",
+ docs: {
+ description:
+ "Warn when a shortcut matches a common one; prefer Keyboard.Shortcut.Common.* from @raycast/api.",
+ },
+ fixable: "code",
+ schema: [],
+ messages: {
+ useCommon:
+ "This shortcut matches Common.{{name}}. Prefer using Keyboard.Shortcut.Common.{{name}} from @raycast/api.",
+ },
+ },
+ defaultOptions: [],
+ create(context) {
+ return {
+ JSXAttribute(node) {
+ if (
+ node.name.type === AST_NODE_TYPES.JSXIdentifier &&
+ node.name.name === "shortcut" &&
+ node.value &&
+ node.value.type === AST_NODE_TYPES.JSXExpressionContainer &&
+ node.value.expression.type === AST_NODE_TYPES.ObjectExpression
+ ) {
+ const shortcutAst = node.value.expression;
+ const parsed = parseShortcutValue(shortcutAst);
+ if (!parsed) return;
+
+ const match = findMatchingCommon(parsed);
+ if (!match) return;
+
+ // Provide a fix to replace object with Keyboard.Shortcut.Common. and ensure import
+ context.report({
+ node: node.value,
+ messageId: "useCommon",
+ data: { name: match.name },
+ fix: (fixer) => {
+ const fixes = [] as RuleFix[];
+ // Replace object literal
+ fixes.push(
+ fixer.replaceText(
+ node.value as TSESTree.Node,
+ `{Keyboard.Shortcut.Common.${match.name}}`
+ )
+ );
+ // Ensure import
+ const program = context.sourceCode.ast;
+ const importFix = ensureKeyboardImportFix(fixer, program);
+ if (importFix) fixes.push(importFix);
+ return fixes;
+ },
+ });
+ }
+ },
+ };
+ },
+});
diff --git a/package.json b/package.json
index d1ece79..b25de86 100644
--- a/package.json
+++ b/package.json
@@ -48,5 +48,6 @@
},
"peerDependencies": {
"eslint": ">=8.23.0"
- }
+ },
+ "packageManager": "pnpm@9.15.0+sha512.76e2379760a4328ec4415815bcd6628dee727af3779aaa4c914e3944156c4299921a89f976381ee107d41f12cfa4b66681ca9c718f0668fa0831ed4c6d8ba56c"
}
diff --git a/tests/fixtures/multi-platform/package.json b/tests/fixtures/multi-platform/package.json
new file mode 100644
index 0000000..62f5d30
--- /dev/null
+++ b/tests/fixtures/multi-platform/package.json
@@ -0,0 +1,7 @@
+{
+ "name": "multi-platform-extension",
+ "platforms": [
+ "macOS",
+ "windows"
+ ]
+}
diff --git a/tests/fixtures/multi-platform/src/.gitkeep b/tests/fixtures/multi-platform/src/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/tests/fixtures/single-platform/package.json b/tests/fixtures/single-platform/package.json
new file mode 100644
index 0000000..996c6b3
--- /dev/null
+++ b/tests/fixtures/single-platform/package.json
@@ -0,0 +1,6 @@
+{
+ "name": "single-platform-extension",
+ "platforms": [
+ "macOS"
+ ]
+}
diff --git a/tests/fixtures/single-platform/src/.gitkeep b/tests/fixtures/single-platform/src/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/tests/no-ambiguous-platform-shortcut.test.ts b/tests/no-ambiguous-platform-shortcut.test.ts
new file mode 100644
index 0000000..1d1b672
--- /dev/null
+++ b/tests/no-ambiguous-platform-shortcut.test.ts
@@ -0,0 +1,68 @@
+import path from "path";
+
+// @ts-ignore
+import { RuleTester } from "@typescript-eslint/rule-tester";
+import rule from "../lib/rules/no-ambiguous-platform-shortcut";
+
+const multiPlatformFile = path.join(
+ __dirname,
+ "fixtures",
+ "multi-platform",
+ "src",
+ "command.tsx"
+);
+
+const singlePlatformFile = path.join(
+ __dirname,
+ "fixtures",
+ "single-platform",
+ "src",
+ "command.tsx"
+);
+
+const ruleTester = new RuleTester({
+ languageOptions: {
+ parserOptions: {
+ ecmaFeatures: { jsx: true },
+ ecmaVersion: 2020,
+ sourceType: "module",
+ },
+ },
+});
+
+ruleTester.run("no-ambiguous-platform-shortcut", rule, {
+ valid: [
+ // Not multi-platform file (single platform config)
+ {
+ filename: singlePlatformFile,
+ code: ``,
+ },
+ // Multi-platform but includes both modifiers => not ambiguous
+ {
+ filename: multiPlatformFile,
+ code: ``,
+ },
+ // Multi-platform but platform-specific object literal (ignored)
+ {
+ filename: multiPlatformFile,
+ code: ``,
+ },
+ // Non-shortcut attribute
+ {
+ filename: multiPlatformFile,
+ code: ``,
+ },
+ ],
+ invalid: [
+ {
+ filename: multiPlatformFile,
+ code: ``,
+ errors: [{ messageId: "ambiguous" }],
+ },
+ {
+ filename: multiPlatformFile,
+ code: ``,
+ errors: [{ messageId: "ambiguous" }],
+ },
+ ],
+});
diff --git a/tests/no-reserved-shortcut.test.ts b/tests/no-reserved-shortcut.test.ts
new file mode 100644
index 0000000..d90cff9
--- /dev/null
+++ b/tests/no-reserved-shortcut.test.ts
@@ -0,0 +1,44 @@
+// @ts-ignore
+import { RuleTester } from "@typescript-eslint/rule-tester";
+import rule from "../lib/rules/no-reserved-shortcut";
+
+const ruleTester = new RuleTester({
+ languageOptions: {
+ parserOptions: {
+ ecmaFeatures: { jsx: true },
+ ecmaVersion: 2020,
+ sourceType: "module",
+ },
+ },
+});
+
+ruleTester.run("no-reserved-shortcut", rule, {
+ valid: [
+ { code: `` }, // not reserved
+ {
+ code: ``,
+ }, // extra prop -> ignored
+ {
+ code: ``,
+ }, // platform form ignored
+ { code: `` },
+ ],
+ invalid: [
+ {
+ code: ``,
+ errors: [{ messageId: "reserved" }],
+ },
+ {
+ code: ``,
+ errors: [{ messageId: "reserved" }],
+ },
+ {
+ code: ``,
+ errors: [{ messageId: "reserved" }],
+ },
+ {
+ code: ``,
+ errors: [{ messageId: "reserved" }],
+ },
+ ],
+});
diff --git a/tests/prefer-common-shortcut.test.ts b/tests/prefer-common-shortcut.test.ts
new file mode 100644
index 0000000..8bc417b
--- /dev/null
+++ b/tests/prefer-common-shortcut.test.ts
@@ -0,0 +1,85 @@
+// @ts-ignore
+import { RuleTester } from "@typescript-eslint/rule-tester";
+import rule from "../lib/rules/prefer-common-shortcut";
+
+const ruleTester = new RuleTester({
+ languageOptions: {
+ parserOptions: {
+ ecmaFeatures: { jsx: true },
+ ecmaVersion: 2020,
+ sourceType: "module",
+ },
+ },
+});
+
+ruleTester.run("prefer-common-shortcut", rule, {
+ valid: [
+ // Already using Common
+ {
+ code: `
+ import { Keyboard } from "@raycast/api";
+ const C = () => ;
+ `,
+ },
+ // Not a shortcut attribute
+ { code: `` },
+ // Non-literal expression
+ { code: `` },
+ // Single-form that doesn't match any 'same across platforms' common shortcut
+ {
+ code: ``,
+ },
+ ],
+ invalid: [
+ // Platform specific form
+ {
+ code: `
+ const C = () => (
+
+ );
+ `,
+ errors: [{ messageId: "useCommon" }],
+ output: `
+ import { Keyboard } from "@raycast/api";
+const C = () => (
+
+ );
+ `,
+ },
+ // Simple form matching a cross-platform identical common shortcut (Remove)
+ {
+ code: `
+ import { Keyboard } from "@raycast/api";
+ const C = () => ;
+ `,
+ errors: [{ messageId: "useCommon" }],
+ output: `
+ import { Keyboard } from "@raycast/api";
+ const C = () => ;
+ `,
+ },
+ // Adds named import when there is an existing import without Keyboard
+ {
+ code: `
+ import { Icon } from "@raycast/api";
+ const C = () => ;
+ `,
+ errors: [{ messageId: "useCommon" }],
+ output: `
+ import { Icon, Keyboard } from "@raycast/api";
+ const C = () => ;
+ `,
+ },
+ // Creates a new import if none exists
+ {
+ code: `
+ const C = () => ;
+ `,
+ errors: [{ messageId: "useCommon" }],
+ output: `
+ import { Keyboard } from "@raycast/api";
+const C = () => ;
+ `,
+ },
+ ],
+});