chore: import upstream snapshot with attribution
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,198 @@
|
||||
// Package cursor implements the Gortex init integration for
|
||||
// Cursor. Writes .cursor/mcp.json (project-level) and, when --global
|
||||
// is effective, ~/.cursor/mcp.json (user-level).
|
||||
//
|
||||
// Schema: standard {"mcpServers": {<name>: {command, args, env}}}.
|
||||
package cursor
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
|
||||
"github.com/zzet/gortex/internal/agents"
|
||||
"github.com/zzet/gortex/internal/agents/internalutil"
|
||||
)
|
||||
|
||||
const Name = "cursor"
|
||||
const DocsURL = "https://docs.cursor.com/en/context/mcp"
|
||||
|
||||
type Adapter struct{}
|
||||
|
||||
func New() *Adapter { return &Adapter{} }
|
||||
func (a *Adapter) Name() string { return Name }
|
||||
func (a *Adapter) DocsURL() string { return DocsURL }
|
||||
|
||||
// cursorUserDataDir is the OS-default directory where Cursor stores
|
||||
// settings when the CLI has never created ~/.cursor/. Detecting it
|
||||
// avoids false negatives for users who use the app daily but have no
|
||||
// `cursor` shell helper on PATH yet.
|
||||
func cursorUserDataDir(home string) string {
|
||||
if home == "" {
|
||||
return ""
|
||||
}
|
||||
switch runtime.GOOS {
|
||||
case "darwin":
|
||||
return filepath.Join(home, "Library", "Application Support", "Cursor")
|
||||
case "windows":
|
||||
return filepath.Join(home, "AppData", "Roaming", "Cursor")
|
||||
default:
|
||||
// Linux and other Unix targets Cursor ships for.
|
||||
return filepath.Join(home, ".config", "Cursor")
|
||||
}
|
||||
}
|
||||
|
||||
// Detect succeeds when any of: project has .cursor/, user has
|
||||
// ~/.cursor/, Cursor's application data directory exists, or the
|
||||
// `cursor` CLI is on PATH.
|
||||
func (a *Adapter) Detect(env agents.Env) (bool, error) {
|
||||
if _, err := os.Stat(filepath.Join(env.Root, ".cursor")); err == nil {
|
||||
return true, nil
|
||||
}
|
||||
if env.Home != "" {
|
||||
if _, err := os.Stat(filepath.Join(env.Home, ".cursor")); err == nil {
|
||||
return true, nil
|
||||
}
|
||||
if dir := cursorUserDataDir(env.Home); dir != "" {
|
||||
if _, err := os.Stat(dir); err == nil {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
if p, err := exec.LookPath("cursor"); err == nil && p != "" {
|
||||
return true, nil
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func (a *Adapter) Plan(env agents.Env) (*agents.Plan, error) {
|
||||
p := &agents.Plan{Files: []agents.FileAction{
|
||||
{Path: mcpConfigPath(env), Action: agents.ActionWouldMerge, Keys: []string{"mcpServers"}},
|
||||
}}
|
||||
if env.Mode != agents.ModeGlobal {
|
||||
p.Files = append(p.Files, agents.FileAction{
|
||||
Path: workflowRulePath(env), Action: agents.ActionWouldCreate,
|
||||
Keys: []string{"workflow-rule"},
|
||||
})
|
||||
}
|
||||
if env.Mode != agents.ModeGlobal && env.SkillsRouting != "" {
|
||||
p.Files = append(p.Files, agents.FileAction{
|
||||
Path: communitiesRulePath(env), Action: agents.ActionWouldCreate,
|
||||
Keys: []string{"communities-rule"},
|
||||
})
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// mcpConfigPath returns the mcp.json path for the given mode.
|
||||
// Project mode: .cursor/mcp.json; global mode: ~/.cursor/mcp.json.
|
||||
// Cursor reads both and prefers project when a key is defined in
|
||||
// both.
|
||||
func mcpConfigPath(env agents.Env) string {
|
||||
if env.Mode == agents.ModeGlobal && env.Home != "" {
|
||||
return filepath.Join(env.Home, ".cursor", "mcp.json")
|
||||
}
|
||||
return filepath.Join(env.Root, ".cursor", "mcp.json")
|
||||
}
|
||||
|
||||
// communitiesRulePath returns the project-scoped MDC file carrying
|
||||
// the regenerated community-routing block. Gortex owns this file
|
||||
// end-to-end; each `gortex init` overwrites it.
|
||||
//
|
||||
// Cursor does not support user-level MDC rules (those live in the
|
||||
// app's Settings UI), so this is always project-scoped.
|
||||
func communitiesRulePath(env agents.Env) string {
|
||||
return filepath.Join(env.Root, ".cursor", "rules", "gortex-communities.mdc")
|
||||
}
|
||||
|
||||
// workflowRulePath is the stable Cursor rule that steers the agent
|
||||
// toward Gortex MCP tools. Regenerated on every `gortex init` so
|
||||
// wording stays aligned with shipped analyzers.
|
||||
func workflowRulePath(env agents.Env) string {
|
||||
return filepath.Join(env.Root, ".cursor", "rules", "gortex-workflow.mdc")
|
||||
}
|
||||
|
||||
// workflowRuleBody is intentionally concise: Cursor injects this on
|
||||
// every turn (alwaysApply) and we want high signal without drowning
|
||||
// project-specific rules.
|
||||
const workflowRuleBody = `## Gortex in Cursor
|
||||
|
||||
This repository wires the **gortex** MCP server via .cursor/mcp.json (merge-managed by Gortex).
|
||||
|
||||
**MANDATORY: use graph tools, not blind file reads**
|
||||
|
||||
You **MUST** prefer Gortex graph queries over text search and whole-file opens on every task. These are not suggestions.
|
||||
|
||||
- **Start** a new chat with **index_health** to confirm the daemon/index (cheap); use **graph_stats** only when you need node/edge counts or multi-repo orientation.
|
||||
- **Use** **search_symbols**, **get_symbol_source**, **get_file_summary**, **get_call_chain**, **find_usages**, and **smart_context** instead of opening whole files or guessing with text search.
|
||||
- Before any signature or API change, **run** **verify_change**; for test selection **run** **get_test_targets**.
|
||||
|
||||
**MANDATORY: session memory**
|
||||
|
||||
- **At session start**, call **distill_session** to recover decisions, pinned notes, and recent excerpts saved in prior sessions in this workspace.
|
||||
- **At every decision point** (picking an approach, rejecting an alternative, spotting a non-obvious constraint), call **save_note** with ` + "`tags:\"decision\"`" + ` and mention affected symbol IDs in the body — they auto-link.
|
||||
- **Before editing a symbol you've touched before**, call **query_notes** with ` + "`symbol_id:\"<id>\"`" + ` to surface prior warnings and decisions.
|
||||
|
||||
**MANDATORY: development memories (cross-session)**
|
||||
|
||||
- **Immediately after smart_context** on every task, call **surface_memories** with ` + "`task:\"<task>\"`" + ` and ` + "`symbol_ids:\"<top hits>\"`" + ` — returns memories ranked by anchor overlap, importance, pinning, recency.
|
||||
- **When you find a durable invariant, gotcha, or decision worth teaching the team**, call **store_memory** with ` + "`kind:\"<invariant|gotcha|convention|decision>\"`" + `, ` + "`symbol_ids:\"<id>\"`" + `, ` + "`importance:5`" + `. Pin load-bearing memories. Use ` + "`supersedes:\"<old-id>\"`" + ` when newer knowledge replaces older.
|
||||
- Memories are workspace-wide and outlive sessions, agents, and teammates — every future agent inherits them.
|
||||
`
|
||||
|
||||
func (a *Adapter) Apply(env agents.Env, opts agents.ApplyOpts) (*agents.Result, error) {
|
||||
res := &agents.Result{Name: Name, DocsURL: DocsURL}
|
||||
detected, _ := a.Detect(env)
|
||||
res.Detected = detected
|
||||
if !detected && !opts.ForceDetect {
|
||||
internalutil.Logf(env.Stderr, "[gortex init] skip Cursor setup (Cursor not detected)")
|
||||
return res, nil
|
||||
}
|
||||
internalutil.Logf(env.Stderr, "[gortex init] setting up Cursor IDE integration...")
|
||||
|
||||
path := mcpConfigPath(env)
|
||||
action, err := agents.MergeJSON(env.Stderr, path, func(root map[string]any, _ bool) (bool, error) {
|
||||
// Global ~/.cursor/mcp.json is read by Cursor across every
|
||||
// project, so cwd at MCP-launch time is unrelated to the open
|
||||
// repo (typically $HOME). Use the daemon-proxy entry; never
|
||||
// the legacy `--index .` shape that fails the entry-point
|
||||
// handshake on home. See gortexhq/gortex#19.
|
||||
if env.Mode == agents.ModeGlobal {
|
||||
return agents.UpsertMCPServerWithMigration(root, "gortex", agents.GlobalGortexMCPEntry(), opts), nil
|
||||
}
|
||||
return agents.UpsertMCPServer(root, "gortex", agents.DefaultGortexMCPEntry(), opts), nil
|
||||
}, opts)
|
||||
if err != nil {
|
||||
return res, err
|
||||
}
|
||||
res.Files = append(res.Files, action)
|
||||
|
||||
// Workflow MDC — project-local only (Cursor has no user-level MDC
|
||||
// on disk). Tells the agent to reach for MCP graph tools first.
|
||||
if env.Mode != agents.ModeGlobal {
|
||||
wfBody := agents.CursorMDCFrontmatter(workflowRuleBody)
|
||||
wfAction, err := agents.WriteOwnedFile(env.Stderr, workflowRulePath(env), wfBody, opts)
|
||||
if err != nil {
|
||||
return res, err
|
||||
}
|
||||
res.Files = append(res.Files, wfAction)
|
||||
}
|
||||
|
||||
// Community-routing MDC file — always written fresh on init
|
||||
// so the routing tracks the current graph. Skipped in global
|
||||
// mode (file is per-repo) and when --no-skills / no
|
||||
// communities qualify.
|
||||
if env.Mode != agents.ModeGlobal && env.SkillsRouting != "" {
|
||||
body := agents.CursorMDCFrontmatter(
|
||||
agents.CommunitiesStartMarker + "\n" + env.SkillsRouting + "\n" + agents.CommunitiesEndMarker + "\n")
|
||||
ruleAction, err := agents.WriteOwnedFile(env.Stderr, communitiesRulePath(env), body, opts)
|
||||
if err != nil {
|
||||
return res, err
|
||||
}
|
||||
res.Files = append(res.Files, ruleAction)
|
||||
}
|
||||
|
||||
res.Configured = true
|
||||
return res, nil
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
package cursor
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/zzet/gortex/internal/agents"
|
||||
"github.com/zzet/gortex/internal/agents/agentstest"
|
||||
)
|
||||
|
||||
// TestCursorCreatesMergesAndSkips covers the three behavioural
|
||||
// phases every adapter must honour:
|
||||
// - initial create on an empty project (via .cursor/ sentinel)
|
||||
// - re-run is a no-op (idempotent)
|
||||
// - merge into an existing mcp.json preserves user keys
|
||||
func TestCursorCreatesMergesAndSkips(t *testing.T) {
|
||||
env, _ := agentstest.NewEnv(t)
|
||||
|
||||
// Sentinel: create .cursor/ so Detect reports true. Without
|
||||
// this the adapter would skip with "not detected".
|
||||
if err := os.MkdirAll(filepath.Join(env.Root, ".cursor"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
a := New()
|
||||
|
||||
// Phase 1 — create.
|
||||
res, err := a.Apply(env, agents.ApplyOpts{})
|
||||
if err != nil {
|
||||
t.Fatalf("apply: %v", err)
|
||||
}
|
||||
if !res.Detected {
|
||||
t.Fatal("expected Detected=true after creating .cursor/")
|
||||
}
|
||||
if !res.Configured {
|
||||
t.Fatal("expected Configured=true after first apply")
|
||||
}
|
||||
// Three creates: .cursor/mcp.json, gortex-workflow.mdc (always-on MCP
|
||||
// guidance), and gortex-communities.mdc (routing from stub SkillsRouting).
|
||||
agentstest.AssertCountsByAction(t, res, map[agents.ActionKind]int{agents.ActionCreate: 3})
|
||||
mcp := agentstest.ReadJSON(t, filepath.Join(env.Root, ".cursor", "mcp.json"))
|
||||
servers := mcp["mcpServers"].(map[string]any)
|
||||
if _, ok := servers["gortex"]; !ok {
|
||||
t.Fatalf("gortex server missing: %v", servers)
|
||||
}
|
||||
|
||||
// Phase 2 — idempotent re-run.
|
||||
agentstest.AssertIdempotent(t, a, env)
|
||||
}
|
||||
|
||||
// TestCursorGlobalWritesProxyEntry confirms that user-level installs
|
||||
// (`gortex install`, env.Mode == ModeGlobal) emit the daemon-proxy
|
||||
// entry — never the legacy `--index . --watch` shape that resolves
|
||||
// against the launch cwd and fails Cursor's global-config handshake.
|
||||
// See gortexhq/gortex#19.
|
||||
func TestCursorGlobalWritesProxyEntry(t *testing.T) {
|
||||
env, _ := agentstest.NewEnv(t)
|
||||
env.Mode = agents.ModeGlobal
|
||||
// Sentinel: ~/.cursor exists so Detect succeeds in global mode.
|
||||
if err := os.MkdirAll(filepath.Join(env.Home, ".cursor"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
res, err := New().Apply(env, agents.ApplyOpts{})
|
||||
if err != nil {
|
||||
t.Fatalf("apply: %v", err)
|
||||
}
|
||||
if !res.Configured {
|
||||
t.Fatal("expected Configured=true for global install")
|
||||
}
|
||||
|
||||
mcp := agentstest.ReadJSON(t, filepath.Join(env.Home, ".cursor", "mcp.json"))
|
||||
servers := mcp["mcpServers"].(map[string]any)
|
||||
entry, ok := servers["gortex"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("gortex entry missing in global mcp.json: %v", servers)
|
||||
}
|
||||
args, _ := entry["args"].([]any)
|
||||
if len(args) == 0 || args[0] != "mcp" {
|
||||
t.Fatalf("args[0] = %v, want \"mcp\"; full args=%v", args, args)
|
||||
}
|
||||
// Canonical shape is bare ["mcp"] — no --index, no --proxy.
|
||||
if len(args) != 1 {
|
||||
t.Errorf("global cursor entry must emit canonical [mcp], got %v", args)
|
||||
}
|
||||
for _, a := range args {
|
||||
if s, _ := a.(string); s == "--index" || s == "--proxy" {
|
||||
t.Errorf("global cursor entry must not pass %q: %v", s, args)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestCursorGlobalMigratesLegacyIndexEntry exercises the migration
|
||||
// path. A user upgrading from a version that wrote the legacy
|
||||
// `--index . --watch` shape should get their global config rewritten
|
||||
// to the daemon-proxy form on the next `gortex install`, without
|
||||
// having to pass --force.
|
||||
func TestCursorGlobalMigratesLegacyIndexEntry(t *testing.T) {
|
||||
env, _ := agentstest.NewEnv(t)
|
||||
env.Mode = agents.ModeGlobal
|
||||
cursorDir := filepath.Join(env.Home, ".cursor")
|
||||
if err := os.MkdirAll(cursorDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Seed the legacy entry that issue 19 reported in the wild.
|
||||
mcpPath := filepath.Join(cursorDir, "mcp.json")
|
||||
agentstest.WriteJSON(t, mcpPath, map[string]any{
|
||||
"mcpServers": map[string]any{
|
||||
"gortex": map[string]any{
|
||||
"command": "gortex",
|
||||
"args": []any{"mcp", "--index", ".", "--watch"},
|
||||
"env": map[string]any{"GORTEX_INDEX_WORKERS": "8"},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
res, err := New().Apply(env, agents.ApplyOpts{})
|
||||
if err != nil {
|
||||
t.Fatalf("apply: %v", err)
|
||||
}
|
||||
if !res.Configured {
|
||||
t.Fatal("expected Configured=true after migration")
|
||||
}
|
||||
|
||||
after := agentstest.ReadJSON(t, mcpPath)
|
||||
servers := after["mcpServers"].(map[string]any)
|
||||
entry := servers["gortex"].(map[string]any)
|
||||
args, _ := entry["args"].([]any)
|
||||
for _, a := range args {
|
||||
if s, _ := a.(string); s == "--index" {
|
||||
t.Fatalf("migration left legacy --index in args: %v", args)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestCursorGlobalPreservesUserCustomization keeps the migration
|
||||
// honest: a user who pointed their global gortex entry at a custom
|
||||
// wrapper script must not have it silently overwritten.
|
||||
func TestCursorGlobalPreservesUserCustomization(t *testing.T) {
|
||||
env, _ := agentstest.NewEnv(t)
|
||||
env.Mode = agents.ModeGlobal
|
||||
cursorDir := filepath.Join(env.Home, ".cursor")
|
||||
if err := os.MkdirAll(cursorDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mcpPath := filepath.Join(cursorDir, "mcp.json")
|
||||
agentstest.WriteJSON(t, mcpPath, map[string]any{
|
||||
"mcpServers": map[string]any{
|
||||
"gortex": map[string]any{
|
||||
"command": "/opt/wrappers/my-gortex.sh",
|
||||
"args": []any{"mcp"},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if _, err := New().Apply(env, agents.ApplyOpts{}); err != nil {
|
||||
t.Fatalf("apply: %v", err)
|
||||
}
|
||||
after := agentstest.ReadJSON(t, mcpPath)
|
||||
servers := after["mcpServers"].(map[string]any)
|
||||
entry := servers["gortex"].(map[string]any)
|
||||
if entry["command"] != "/opt/wrappers/my-gortex.sh" {
|
||||
t.Fatalf("user-customized command was overwritten: %v", entry)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCursorMergeIntoExistingPreservesUserKeys confirms that the
|
||||
// adapter writes alongside — not over — a user's pre-existing MCP
|
||||
// server entry. This is the load-bearing property of MergeJSON.
|
||||
func TestCursorMergeIntoExistingPreservesUserKeys(t *testing.T) {
|
||||
env, _ := agentstest.NewEnv(t)
|
||||
if err := os.MkdirAll(filepath.Join(env.Root, ".cursor"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mcpPath := filepath.Join(env.Root, ".cursor", "mcp.json")
|
||||
agentstest.WriteJSON(t, mcpPath, map[string]any{
|
||||
"mcpServers": map[string]any{
|
||||
"user-server": map[string]any{"command": "user-tool"},
|
||||
},
|
||||
})
|
||||
|
||||
res, err := New().Apply(env, agents.ApplyOpts{})
|
||||
if err != nil {
|
||||
t.Fatalf("apply: %v", err)
|
||||
}
|
||||
// mcp.json pre-exists → merge; workflow + communities rules are new.
|
||||
agentstest.AssertCountsByAction(t, res, map[agents.ActionKind]int{agents.ActionMerge: 1, agents.ActionCreate: 2})
|
||||
after := agentstest.ReadJSON(t, mcpPath)
|
||||
servers := after["mcpServers"].(map[string]any)
|
||||
if _, ok := servers["user-server"]; !ok {
|
||||
t.Fatalf("user-server was clobbered: %v", servers)
|
||||
}
|
||||
if _, ok := servers["gortex"]; !ok {
|
||||
t.Fatalf("gortex missing after merge: %v", servers)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user