diff --git a/.cursor/rules/commonly.mdc b/.cursor/rules/commonly.mdc new file mode 100644 index 0000000000..1baf47963f --- /dev/null +++ b/.cursor/rules/commonly.mdc @@ -0,0 +1,10 @@ +--- +description: Use Commonly as the shared pod for Cursor work +alwaysApply: true +--- + +When a task names a Commonly pod, call `commonly_get_context(podId)` before starting so you have its current priorities and recent messages. Use `commonly_get_messages` when you need more history. Post a concise result or handoff to that same pod with `commonly_post_message`; avoid step-by-step progress chatter. + +Cursor's MCP connection lets this session use Commonly tools. It does not watch for mentions or wake Cursor while no session is open. Do not claim that a Cursor agent is listening for @mentions unless a separate listener has been set up and verified. + +Keep agent keys out of pod messages, URLs, logs, and committed files. For work in this repository, follow [AGENTS.md](../../AGENTS.md) and [REVIEW.md](../../REVIEW.md). diff --git a/.gitignore b/.gitignore index 3f2a43a2ff..a132d8eeb7 100644 --- a/.gitignore +++ b/.gitignore @@ -18,7 +18,10 @@ dist/ *.pem -.cursor/ +/.cursor/* +!/.cursor/rules/ +/.cursor/rules/* +!/.cursor/rules/commonly.mdc # Playwright /test-results/ diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 604a52d729..4ca28560aa 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -1466,6 +1466,8 @@ "runtimeToken": "The key on its own", "claudeCode": "Optional: let Claude Code use the pod in your own sessions", "cursor": "Use the pod from Cursor via MCP — add to ~/.cursor/mcp.json", + "cursorInstall": "Add to Cursor", + "cursorInstallHint": "The link adds Commonly with a placeholder key. Replace it in Cursor’s MCP settings with the key above.", "memory": "Bring its memory (optional)", "claudeCodeBody": "Gives your own Claude Code sessions Commonly’s tools. It does not make {{name}} answer @mentions; the command above does.", "claudeCodeBodyOwn": "Gives your own Claude Code sessions Commonly’s tools.", diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json index c3c3e779c7..745cdc7a83 100644 --- a/frontend/src/i18n/locales/zh-CN.json +++ b/frontend/src/i18n/locales/zh-CN.json @@ -1526,6 +1526,8 @@ "runtimeToken": "仅密钥", "claudeCode": "可选:让 Claude Code 在你自己的会话里使用这个 Pod", "cursor": "通过 MCP 在 Cursor 中使用这个 Pod——添加到 ~/.cursor/mcp.json", + "cursorInstall": "添加到 Cursor", + "cursorInstallHint": "该链接会以占位密钥添加 Commonly。请在 Cursor 的 MCP 设置中将其替换为上面的密钥。", "memory": "导入它的记忆(可选)", "claudeCodeBody": "为你自己的 Claude Code 会话加上 Commonly 的工具。它不会让 {{name}} 回复 @提及,上面那条命令才会。", "claudeCodeBodyOwn": "为你自己的 Claude Code 会话加上 Commonly 的工具。", diff --git a/frontend/src/setupTests.ts b/frontend/src/setupTests.ts index ff6f192aaa..ed4ae677b5 100644 --- a/frontend/src/setupTests.ts +++ b/frontend/src/setupTests.ts @@ -1,7 +1,11 @@ // Import jest-dom matchers import '@testing-library/jest-dom'; +import { TextEncoder } from 'util'; import './i18n'; +// jsdom omits this browser API; use Node's implementation for UTF-8 encoding. +Object.assign(global, { TextEncoder }); + // Mock axios jest.mock('axios', () => ({ __esModule: true, diff --git a/frontend/src/v2/__tests__/V2AgentBYOListenSnippet.test.tsx b/frontend/src/v2/__tests__/V2AgentBYOListenSnippet.test.tsx index 8e82cb220c..c86e6b97aa 100644 --- a/frontend/src/v2/__tests__/V2AgentBYOListenSnippet.test.tsx +++ b/frontend/src/v2/__tests__/V2AgentBYOListenSnippet.test.tsx @@ -88,6 +88,34 @@ describe('BYO listener snippet', () => { } }); + test('the Cursor install link uses this instance without putting the issued key in its URL', async () => { + const previousApiUrl = process.env.REACT_APP_API_URL; + process.env.REACT_APP_API_URL = 'https://api.example.test/'; + try { + await issueToken(); + const href = screen.getByRole('link', { name: 'Add to Cursor', hidden: true }).getAttribute('href') || ''; + const config = JSON.parse(atob(decodeURIComponent(href.split('config=')[1]))); + expect(config.env).toEqual({ + COMMONLY_API_URL: 'https://api.example.test', + COMMONLY_AGENT_TOKEN: 'PASTE_AGENT_TOKEN_HERE', + }); + expect(href).not.toContain('cm_agent_test_token'); + expect(href).not.toContain('api.commonly.me'); + } finally { + if (previousApiUrl === undefined) delete process.env.REACT_APP_API_URL; + else process.env.REACT_APP_API_URL = previousApiUrl; + } + }); + + test('the Cursor link leads its guidance, while the JSON still names its destination', async () => { + await issueToken(); + const link = screen.getByRole('link', { name: 'Add to Cursor', hidden: true }); + const hint = link.nextElementSibling; + expect(hint).toHaveClass('v2-byo__hint'); + expect(hint).toHaveTextContent('The link adds Commonly with a placeholder key.'); + expect(link.closest('.v2-byo__snippet')).toHaveTextContent('~/.cursor/mcp.json'); + }); + // QA BYO walk 1 (2026-10-16): four equal Copy boxes, and the one titled // "Claude Code" was the MCP line, which does not make the agent answer. The // command that does is THE box: first, and the only one not marked optional diff --git a/frontend/src/v2/__tests__/V2ConnectPage.test.tsx b/frontend/src/v2/__tests__/V2ConnectPage.test.tsx index 88df75318a..32ed6c3441 100644 --- a/frontend/src/v2/__tests__/V2ConnectPage.test.tsx +++ b/frontend/src/v2/__tests__/V2ConnectPage.test.tsx @@ -53,6 +53,14 @@ describe('V2ConnectPage', () => { expect(mcpSnippet).toContain('COMMONLY_API_URL=https://api.example.test'); expect(cliSnippet).not.toContain('api.commonly.me'); expect(mcpSnippet).not.toContain('api.commonly.me'); + const cursorHref = screen.getByRole('link', { name: 'Add Commonly to Cursor' }).getAttribute('href') || ''; + expect(cursorHref).toMatch(/^cursor:\/\/anysphere\.cursor-deeplink\/mcp\/install\?name=commonly&config=/); + const cursorConfig = JSON.parse(atob(decodeURIComponent(cursorHref.split('config=')[1]))); + expect(cursorConfig.env).toEqual({ + COMMONLY_API_URL: 'https://api.example.test', + COMMONLY_AGENT_TOKEN: 'PASTE_AGENT_TOKEN_HERE', + }); + expect(cursorHref).not.toContain('api.commonly.me'); fireEvent.click(screen.getAllByRole('button', { name: 'Copy' })[0]); await waitFor(() => expect(clipboard.writeText).toHaveBeenCalledWith(cliSnippet)); } finally { diff --git a/frontend/src/v2/__tests__/cursorMcpInstallLink.test.ts b/frontend/src/v2/__tests__/cursorMcpInstallLink.test.ts new file mode 100644 index 0000000000..41766fec9d --- /dev/null +++ b/frontend/src/v2/__tests__/cursorMcpInstallLink.test.ts @@ -0,0 +1,13 @@ +import cursorMcpInstallLink from '../utils/cursorMcpInstallLink'; + +test('Cursor config preserves Unicode API URLs as UTF-8', () => { + const apiUrl = 'https://例子.test/连接/'; + const href = cursorMcpInstallLink(apiUrl); + const encodedConfig = new URL(href).searchParams.get('config') || ''; + const config = JSON.parse(Buffer.from(encodedConfig, 'base64').toString('utf8')); + + expect(config.env).toEqual({ + COMMONLY_API_URL: 'https://例子.test/连接', + COMMONLY_AGENT_TOKEN: 'PASTE_AGENT_TOKEN_HERE', + }); +}); diff --git a/frontend/src/v2/components/V2AgentBYO.tsx b/frontend/src/v2/components/V2AgentBYO.tsx index fb35059d69..d7ac6ed5b9 100644 --- a/frontend/src/v2/components/V2AgentBYO.tsx +++ b/frontend/src/v2/components/V2AgentBYO.tsx @@ -31,6 +31,7 @@ import { DEFAULT_INSTALL_SCOPES } from '../utils/agentInstallScopes'; import { hostedCapNotice } from './hostedCapNotice'; import { readPodVisits } from '../lib/podRecency'; import { AgentConnection, connectionLabel } from '../utils/agentConnection'; +import cursorMcpInstallLink from '../utils/cursorMcpInstallLink'; const DEFAULT_AGENT_NAME = 'my-mcp-agent'; // The token routes refuse a name that belongs to an agent Commonly runs (the @@ -897,6 +898,7 @@ const V2AgentBYO: React.FC = () => { }, }, null, 2) : ''; + const cursorInstallHref = cursorMcpInstallLink(apiUrl); return ( {
{t('agentByo.snippets.cursor')}
+ {t('agentByo.snippets.cursorInstall')} +

{t('agentByo.snippets.cursorInstallHint')}

{cursorSnippet}
+
+ Add Commonly to Cursor + Add your agent key in Cursor’s MCP settings. +
Manage connected CLI devices diff --git a/frontend/src/v2/utils/cursorMcpInstallLink.ts b/frontend/src/v2/utils/cursorMcpInstallLink.ts new file mode 100644 index 0000000000..9526c4ec56 --- /dev/null +++ b/frontend/src/v2/utils/cursorMcpInstallLink.ts @@ -0,0 +1,19 @@ +// Cursor's install deeplink contains its MCP configuration in the URL. Keep +// agent keys out of it; the user adds the key in Cursor after installation. +export const CURSOR_TOKEN_PLACEHOLDER = 'PASTE_AGENT_TOKEN_HERE'; + +const cursorMcpInstallLink = (apiUrl: string): string => { + const config = { + command: 'npx', + args: ['-y', '@commonlyai/mcp'], + env: { + COMMONLY_API_URL: apiUrl.replace(/\/+$/, ''), + COMMONLY_AGENT_TOKEN: CURSOR_TOKEN_PLACEHOLDER, + }, + }; + const bytes = new TextEncoder().encode(JSON.stringify(config)); + const encodedConfig = btoa(Array.from(bytes, (byte) => String.fromCharCode(byte)).join('')); + return `cursor://anysphere.cursor-deeplink/mcp/install?name=commonly&config=${encodeURIComponent(encodedConfig)}`; +}; + +export default cursorMcpInstallLink; diff --git a/frontend/src/v2/v2.css b/frontend/src/v2/v2.css index 1c72c468fb..a656e93d76 100644 --- a/frontend/src/v2/v2.css +++ b/frontend/src/v2/v2.css @@ -6481,6 +6481,13 @@ body.modern-ui.v2-canvas { color: var(--v2-accent-text); font: 500 13px/20px var(--v2-font); } +.v2-root a.v2-byo__cursor-link { + align-self: flex-start; + color: var(--v2-accent-text); + font: 500 13px/20px var(--v2-font); + text-decoration: none; +} +.v2-root a.v2-byo__cursor-link:hover { text-decoration: underline; } .v2-root button.v2-byo__secondary { align-self: flex-start; padding: 10px 18px; @@ -6762,6 +6769,17 @@ body.modern-ui.v2-canvas { text-decoration: underline; } +.v2-connect__cursor { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 4px 12px; + margin-top: 12px; + color: var(--v2-text-tertiary); + font: 400 13px/20px var(--v2-font); +} +.v2-root .v2-connect__cursor .v2-connect__link { margin-top: 0; } + .v2-connect__actions { display: flex; flex-wrap: wrap;