Cursor SDK local agents mis-scope project skills for git subdirectories and symlinked skill layouts

Describe the Bug

Cursor SDK local agents do not appear to scope project skill discovery strictly
to the local.cwd workspace passed to Agent.create.

When local.cwd points at a subdirectory inside a larger git repository, the
SDK agent can receive skills from the git repository root. Separately, when a
workspace exposes project skills through symlinked skill directories, those
skills are not surfaced to the SDK agent even though the symlinks are located
under the workspace’s .cursor/skills or .agents/skills directory.

This creates a mismatch with Cursor IDE/CLI workspace context behavior and
makes it difficult to build multi-root SDK workspaces that expose selected
repository skills through a synthetic workspace layout.

Steps to Reproduce

This is a self-contained repro for Cursor SDK local agent skill discovery
differences around:

  • local.cwd pointing at a subdirectory inside a git repository.
  • Project skills exposed through symlinks.

Run

CURSOR_API_KEY=$CURSOR_API_KEY ./reproduce.sh

The script creates a temporary workspace, initializes a git repository, writes
several SKILL.md files, starts fresh SDK bridge processes for each case, and
asks the SDK agent to list only the skill names injected into its prompt.

It requires uv, git, and CURSOR_API_KEY.

reproduce.sh

#!/usr/bin/env bash
set -euo pipefail

if [[ -z "${CURSOR_API_KEY:-}" ]]; then
  echo "CURSOR_API_KEY is required" >&2
  exit 1
fi

if ! command -v uv >/dev/null 2>&1; then
  echo "uv is required for this repro" >&2
  exit 1
fi

if ! command -v git >/dev/null 2>&1; then
  echo "git is required for this repro" >&2
  exit 1
fi

REPRO_ROOT="$(mktemp -d "${TMPDIR:-/tmp}/cursor-sdk-skill-repro-XXXXXX")"
export REPRO_ROOT

echo "Repro root: ${REPRO_ROOT}"
echo

uv run --with "cursor-sdk==0.1.5" python - <<'PY'
from __future__ import annotations

import json
import os
import shutil
import subprocess
import sys
from dataclasses import dataclass
from importlib.metadata import version
from pathlib import Path

from cursor_sdk import CursorClient, LocalAgentOptions


MODEL = os.environ.get("CURSOR_SDK_REPRO_MODEL", "default")
API_KEY = os.environ["CURSOR_API_KEY"]
ROOT = Path(os.environ["REPRO_ROOT"]).resolve()

PROMPT = """
Do not read files and do not use tools.

Only use the available skills/additional context already injected into your
prompt. List visible skill names that contain one of these strings:

- control-real
- external-linked
- nested-real
- root-git

Return exactly one line:
VISIBLE_SKILLS: <comma-separated names or none>
""".strip()


@dataclass(frozen=True)
class Case:
    name: str
    cwd: Path
    expected_present: tuple[str, ...]
    expected_absent: tuple[str, ...]
    note: str


def main() -> int:
    print(f"cursor-sdk version: {version('cursor-sdk')}")
    print(f"model: {MODEL}")
    print()

    cases = create_fixture(ROOT)
    results = [run_case(case) for case in cases]

    print("\n=== Summary ===")
    all_expected = True
    for case, text, present, absent in results:
        expected_present_ok = all(name in text for name in case.expected_present)
        expected_absent_ok = all(name not in text for name in case.expected_absent)
        case_ok = expected_present_ok and expected_absent_ok
        all_expected = all_expected and case_ok
        print(
            json.dumps(
                {
                    "case": case.name,
                    "expected_present": case.expected_present,
                    "expected_absent": case.expected_absent,
                    "expected_present_ok": expected_present_ok,
                    "expected_absent_ok": expected_absent_ok,
                    "case_ok": case_ok,
                    "note": case.note,
                },
                ensure_ascii=False,
            )
        )

    print()
    print("Interpretation:")
    print("- control-real-outside-git should pass; it proves SDK can inject real project skills.")
    print("- symlink-outside-git currently fails; external symlinked skills are filtered out.")
    print("- git-subworkspace currently leaks root-git-skill even though local.cwd points at the nested workspace.")
    print()
    print(f"Temporary files are left in: {ROOT}")
    print("Remove them with:")
    print(f"  rm -rf {ROOT}")

    # Exit 0 when the known bug is reproduced:
    # control passes, buggy cases do NOT match their documented expectations.
    control_text = results[0][1]
    symlink_text = results[1][1]
    git_text = results[2][1]
    bug_reproduced = (
        "control-real-cursor-skill" in control_text
        and "external-linked-cursor-skill" not in symlink_text
        and "nested-real-cursor-skill" in git_text
        and "root-git-cursor-skill" in git_text
    )
    if bug_reproduced:
        print("\nBUG REPRODUCED")
        return 0

    print("\nBUG NOT REPRODUCED: behavior may have changed")
    return 2


def create_fixture(root: Path) -> list[Case]:
    control = root / "control-real-outside-git"
    write_skill(control, ".cursor/skills", "control-real-cursor-skill", "CONTROL_REAL_CURSOR_SENTINEL")
    write_skill(control, ".agents/skills", "control-real-agents-skill", "CONTROL_REAL_AGENTS_SENTINEL")

    external = root / "external-skill-source"
    write_skill(external, ".cursor/skills", "external-linked-cursor-skill", "EXTERNAL_LINKED_CURSOR_SENTINEL")
    write_skill(external, ".agents/skills", "external-linked-agents-skill", "EXTERNAL_LINKED_AGENTS_SENTINEL")

    symlink_workspace = root / "symlink-outside-git"
    (symlink_workspace / ".cursor/skills").mkdir(parents=True)
    (symlink_workspace / ".agents/skills").mkdir(parents=True)
    symlink(
        external / ".cursor/skills/external-linked-cursor-skill",
        symlink_workspace / ".cursor/skills/external-linked-cursor-skill",
    )
    symlink(
        external / ".agents/skills/external-linked-agents-skill",
        symlink_workspace / ".agents/skills/external-linked-agents-skill",
    )

    git_repo = root / "git-parent-repo"
    git_repo.mkdir()
    subprocess.run(["git", "init", "-q"], cwd=git_repo, check=True)
    (git_repo / "README.md").write_text("# git parent repo\n", encoding="utf-8")
    write_skill(git_repo, ".cursor/skills", "root-git-cursor-skill", "ROOT_GIT_CURSOR_SENTINEL")

    subworkspace = git_repo / "nested-workspace"
    write_skill(subworkspace, ".cursor/skills", "nested-real-cursor-skill", "NESTED_REAL_CURSOR_SENTINEL")
    write_skill(subworkspace, ".agents/skills", "nested-real-agents-skill", "NESTED_REAL_AGENTS_SENTINEL")

    return [
        Case(
            name="control-real-outside-git",
            cwd=control,
            expected_present=("control-real-cursor-skill", "control-real-agents-skill"),
            expected_absent=("root-git-cursor-skill",),
            note="Real project skills outside git should be visible.",
        ),
        Case(
            name="symlink-outside-git",
            cwd=symlink_workspace,
            expected_present=("external-linked-cursor-skill", "external-linked-agents-skill"),
            expected_absent=("root-git-cursor-skill",),
            note="Expected if SDK supports symlinked project skills; currently filtered by realpath/source scope.",
        ),
        Case(
            name="git-subworkspace",
            cwd=subworkspace,
            expected_present=("nested-real-cursor-skill", "nested-real-agents-skill"),
            expected_absent=("root-git-cursor-skill",),
            note="Explicit local.cwd points to nested-workspace, but SDK currently surfaces git root skills.",
        ),
    ]


def run_case(case: Case) -> tuple[Case, str, tuple[str, ...], tuple[str, ...]]:
    print(f"=== Case: {case.name} ===")
    print(f"cwd: {case.cwd}")
    print(f"expected_present: {', '.join(case.expected_present)}")
    print(f"expected_absent: {', '.join(case.expected_absent)}")
    try:
        with CursorClient.launch_bridge(workspace=str(case.cwd), timeout=30) as client:
            with client.agents.create(
                model=MODEL,
                api_key=API_KEY,
                local=LocalAgentOptions(cwd=str(case.cwd), setting_sources=["project"]),
            ) as agent:
                result = agent.send(PROMPT).wait()
    except Exception as exc:
        print(f"ERROR: {type(exc).__name__}: {exc}")
        print()
        return case, "", case.expected_present, case.expected_absent

    status = getattr(result, "status", "")
    text = (getattr(result, "result", "") or "").strip()
    print(f"status: {status}")
    print(text)
    print()
    return case, text, case.expected_present, case.expected_absent


def write_skill(root: Path, skill_root: str, name: str, sentinel: str) -> None:
    skill_dir = root / skill_root / name
    skill_dir.mkdir(parents=True, exist_ok=True)
    (root / "README.md").write_text(f"# {root.name}\n", encoding="utf-8")
    (skill_dir / "SKILL.md").write_text(
        "\n".join(
            [
                "---",
                f"name: {name}",
                f"description: Use when {sentinel} is mentioned.",
                "---",
                f"When active, mention {sentinel}.",
                "",
            ]
        ),
        encoding="utf-8",
    )


def symlink(source: Path, target: Path) -> None:
    if target.exists() or target.is_symlink():
        if target.is_dir() and not target.is_symlink():
            shutil.rmtree(target)
        else:
            target.unlink()
    target.symlink_to(source, target_is_directory=True)


if __name__ == "__main__":
    raise SystemExit(main())
PY

Expected Behavior

When local=LocalAgentOptions(cwd=<workspace>, setting_sources=["project"]) is
used, the local SDK agent should load project skills from the provided
workspace path, matching Cursor IDE/CLI project context behavior.

In particular:

  • A subdirectory workspace inside a git repository should surface skills from
    that subdirectory’s .cursor/skills / .agents/skills.
  • If Cursor supports project skill symlink layouts, SDK local agents should
    either follow them consistently or document that external symlinked skills
    are intentionally unsupported.

Operating System

Linux

Version Information

  • Python cursor-sdk==0.1.5
  • TypeScript @cursor/sdk==1.0.15

Additional Information

Actual Behavior

Observed with:

  • Python cursor-sdk==0.1.5
  • TypeScript @cursor/sdk==1.0.15

The SDK agent leaks project skills from the git repository root into a
subdirectory workspace. External symlinked skill directories are also filtered
out.

In the original failing workspace:

local.cwd:
  <repo>/bug-reports/symlink-skill-layouts/01-agents-skills-root-links

expected skill:
  alpha-agents-skill

actual visible skills:
  assistant-ui, cloud, primitives, runtime, setup, streaming, thread-list,
  tools, update, demo-agent

Root Cause Hypothesis

The Python wrapper appears to serialize options correctly:

{
  "local": {
    "cwd": [".../01-agents-skills-root-links"],
    "settingSources": ["SETTING_SOURCE_PROJECT"],
  }
}

The issue appears to be in the shared SDK local bridge/runtime. The minified
runtime code resolves a projectDir/projectRoot from workingDirectory, then
project extensibility uses workspaceState.projectRoot ?? workingDirectory.
Inside a larger git repository, this makes project skill discovery prefer the
git root over the explicit local.cwd.

The runtime source filter also normalizes entries with realpath(). For
external symlinked skills, the real path is outside the allowed project root,
so the skill is filtered even when the symlink itself is under
.cursor/skills or .agents/skills.

Workaround

Materialize project skills/rules into a synthetic workspace root as real files,
then pass that synthetic root as the first cwd, followed by the original repo
roots. Do not rely on external symlinked SKILL.md files or skill directories
for SDK local agents, and do not assume local.cwd scopes skills strictly to a
subdirectory inside a larger git repository.

Does this stop you from using Cursor

Yes - Cursor is unusable

Hi Shurui!
Both bugs are confirmed in the SDK local runtime code.

Your root cause hypothesis and reproduction are accurate. We’re filing this with the SDK team - both sub-bugs will be tracked together since they’re in the same code area.

Your workaround (materializing skills as real files in a synthetic workspace root) is the right approach until this is resolved. We’ll update this thread when there’s progress.

Thanks for the quick reply, Mohit!

When developing applications with the SDK, I expected the agent’s behavior to fully align with the official documentation Agent Skills | Cursor Docs. It would be great if writing tests could be as straightforward as the reproduction script I provided.

Hopefully, the upcoming fix will cover all the expected skill discovery behaviors outlined in the docs to prevent similar issues down the road. Really excited to keep building cool apps with these tools!

In addition, <manually_attached_skills> is currently unavailable in the SDK. When I attach a slash skill command, the agent doesn’t receive the injected context directly. While I understand we can build the command logic within our own application, there is currently no way to inject skill content using a separate XML tag instead of embedding it directly inside the user query.

Noted, we’re logging this as a feature request for the SDK team. The agent.send() API doesn’t currently support explicit skill attachment, so embedding skill content in the prompt is the workaround for now.