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.cwdpointing 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