spawn-cli 0.1.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- spawn_cli/__init__.py +3 -0
- spawn_cli/cli.py +243 -0
- spawn_cli/core/__init__.py +5 -0
- spawn_cli/core/download.py +532 -0
- spawn_cli/core/errors.py +5 -0
- spawn_cli/core/high_level.py +462 -0
- spawn_cli/core/low_level.py +914 -0
- spawn_cli/core/scripts.py +246 -0
- spawn_cli/errors.py +9 -0
- spawn_cli/ide/__init__.py +63 -0
- spawn_cli/ide/_helpers.py +168 -0
- spawn_cli/ide/_stub.py +63 -0
- spawn_cli/ide/_vacancy.py +221 -0
- spawn_cli/ide/claude_code.py +145 -0
- spawn_cli/ide/codex.py +120 -0
- spawn_cli/ide/cursor.py +122 -0
- spawn_cli/ide/gemini_cli.py +130 -0
- spawn_cli/ide/github_copilot.py +167 -0
- spawn_cli/ide/registry.py +142 -0
- spawn_cli/ide/windsurf.py +91 -0
- spawn_cli/io/__init__.py +21 -0
- spawn_cli/io/json_io.py +21 -0
- spawn_cli/io/lock.py +24 -0
- spawn_cli/io/paths.py +23 -0
- spawn_cli/io/text_io.py +16 -0
- spawn_cli/io/toml_io.py +26 -0
- spawn_cli/io/yaml_io.py +32 -0
- spawn_cli/models/__init__.py +63 -0
- spawn_cli/models/config.py +111 -0
- spawn_cli/models/mcp.py +49 -0
- spawn_cli/models/metadata.py +28 -0
- spawn_cli/models/navigation.py +30 -0
- spawn_cli/models/skill.py +29 -0
- spawn_cli/resources/__init__.py +0 -0
- spawn_cli/resources/default_core_config.yaml +5 -0
- spawn_cli/warnings_display.py +35 -0
- spawn_cli-0.1.1.dist-info/METADATA +11 -0
- spawn_cli-0.1.1.dist-info/RECORD +41 -0
- spawn_cli-0.1.1.dist-info/WHEEL +5 -0
- spawn_cli-0.1.1.dist-info/entry_points.txt +2 -0
- spawn_cli-0.1.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
"""Detect vacant IDE-managed filesystem trees after teardown (skills/MCP removals)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import shutil
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def json_value_nonempty(v: object) -> bool:
|
|
11
|
+
"""True if nested JSON-ish value carries user-visible substance."""
|
|
12
|
+
if v is None:
|
|
13
|
+
return False
|
|
14
|
+
if isinstance(v, dict):
|
|
15
|
+
return len(v) > 0
|
|
16
|
+
if isinstance(v, list):
|
|
17
|
+
return len(v) > 0
|
|
18
|
+
if isinstance(v, str):
|
|
19
|
+
return bool(v.strip())
|
|
20
|
+
if isinstance(v, bool):
|
|
21
|
+
return v
|
|
22
|
+
if isinstance(v, (int, float)):
|
|
23
|
+
return v != 0
|
|
24
|
+
return True
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def mcp_json_mcp_servers_format_is_empty(path: Path) -> bool:
|
|
28
|
+
"""True when path is Cursor or Claude-root style JSON ({mcpServers?: ...})."""
|
|
29
|
+
if not path.is_file():
|
|
30
|
+
return False
|
|
31
|
+
try:
|
|
32
|
+
raw = path.read_text(encoding="utf-8")
|
|
33
|
+
data = json.loads(raw)
|
|
34
|
+
except (OSError, json.JSONDecodeError):
|
|
35
|
+
return False
|
|
36
|
+
if not isinstance(data, dict):
|
|
37
|
+
return False
|
|
38
|
+
if data == {}:
|
|
39
|
+
return True
|
|
40
|
+
servers = data.get("mcpServers")
|
|
41
|
+
srv_empty = servers in (None, {}) or (isinstance(servers, dict) and len(servers) == 0)
|
|
42
|
+
if not srv_empty:
|
|
43
|
+
return False
|
|
44
|
+
for k, v in data.items():
|
|
45
|
+
if k != "mcpServers" and json_value_nonempty(v):
|
|
46
|
+
return False
|
|
47
|
+
return True
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def vscode_servers_mcp_json_is_empty(path: Path) -> bool:
|
|
51
|
+
"""True when path is VS Code MCP format (`servers`, `inputs`). Used for .vscode/mcp.json."""
|
|
52
|
+
if not path.is_file():
|
|
53
|
+
return False
|
|
54
|
+
try:
|
|
55
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
56
|
+
except (OSError, json.JSONDecodeError):
|
|
57
|
+
return False
|
|
58
|
+
if not isinstance(data, dict):
|
|
59
|
+
return False
|
|
60
|
+
if data == {}:
|
|
61
|
+
return True
|
|
62
|
+
servers = data.get("servers")
|
|
63
|
+
srv_empty = servers in (None, {}) or (isinstance(servers, dict) and len(servers) == 0)
|
|
64
|
+
inputs = data.get("inputs")
|
|
65
|
+
inp_empty = inputs in (None, []) or (isinstance(inputs, list) and len(inputs) == 0)
|
|
66
|
+
if not (srv_empty and inp_empty):
|
|
67
|
+
return False
|
|
68
|
+
for k, v in data.items():
|
|
69
|
+
if k not in ("servers", "inputs") and json_value_nonempty(v):
|
|
70
|
+
return False
|
|
71
|
+
return True
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def dir_has_any_file(root: Path) -> bool:
|
|
75
|
+
"""True if any regular file exists under root."""
|
|
76
|
+
if not root.is_dir():
|
|
77
|
+
return False
|
|
78
|
+
try:
|
|
79
|
+
for p in root.rglob("*"):
|
|
80
|
+
if p.is_file():
|
|
81
|
+
return True
|
|
82
|
+
except OSError:
|
|
83
|
+
return True
|
|
84
|
+
return False
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def prune_empty_directories_under(container: Path) -> None:
|
|
88
|
+
"""Remove descendant directories bottom-up while empty (excluding container itself)."""
|
|
89
|
+
if not container.is_dir():
|
|
90
|
+
return
|
|
91
|
+
dirs = [p for p in container.rglob("*") if p.is_dir()]
|
|
92
|
+
dirs.sort(key=lambda p: len(p.parts), reverse=True)
|
|
93
|
+
for d in dirs:
|
|
94
|
+
try:
|
|
95
|
+
if not any(d.iterdir()):
|
|
96
|
+
d.rmdir()
|
|
97
|
+
except OSError:
|
|
98
|
+
pass
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def ide_dotdir_is_entirely_removable(root: Path, *, allow_delete_entire: bool) -> bool:
|
|
102
|
+
"""
|
|
103
|
+
Whole-directory removal permitted only when enabled and container has zero files beneath.
|
|
104
|
+
"""
|
|
105
|
+
if not allow_delete_entire or not root.exists():
|
|
106
|
+
return False
|
|
107
|
+
return not dir_has_any_file(root)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def try_unlink_empty_mcp_mcp_servers(path: Path) -> None:
|
|
111
|
+
if mcp_json_mcp_servers_format_is_empty(path):
|
|
112
|
+
path.unlink(missing_ok=True)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def try_unlink_empty_vscode_mcp(path: Path) -> None:
|
|
116
|
+
if vscode_servers_mcp_json_is_empty(path):
|
|
117
|
+
path.unlink(missing_ok=True)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def finalize_standard_dotdir_skills_and_mcp(
|
|
121
|
+
target_root: Path,
|
|
122
|
+
rel_dir: str,
|
|
123
|
+
*,
|
|
124
|
+
allow_delete_entire: bool,
|
|
125
|
+
unlink_settings_json_when_mcp_servers_empty: bool = False,
|
|
126
|
+
) -> None:
|
|
127
|
+
"""Shared layout: IDE dotdir plus optional MCP JSON files."""
|
|
128
|
+
root = target_root / rel_dir
|
|
129
|
+
if not root.is_dir():
|
|
130
|
+
return
|
|
131
|
+
try_unlink_empty_mcp_mcp_servers(root / "mcp.json")
|
|
132
|
+
if unlink_settings_json_when_mcp_servers_empty:
|
|
133
|
+
sj = root / "settings.json"
|
|
134
|
+
if sj.is_file():
|
|
135
|
+
try_unlink_empty_mcp_mcp_servers(sj)
|
|
136
|
+
prune_empty_directories_under(root)
|
|
137
|
+
if ide_dotdir_is_entirely_removable(root, allow_delete_entire=allow_delete_entire):
|
|
138
|
+
shutil.rmtree(root, ignore_errors=True)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def claude_root_mcp_maybe_unlink(target_root: Path) -> None:
|
|
142
|
+
try_unlink_empty_mcp_mcp_servers(target_root / ".mcp.json")
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def claude_settings_maybe_unlink(path: Path) -> None:
|
|
146
|
+
if not path.is_file():
|
|
147
|
+
return
|
|
148
|
+
try:
|
|
149
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
150
|
+
except (OSError, json.JSONDecodeError):
|
|
151
|
+
return
|
|
152
|
+
if data == {}:
|
|
153
|
+
path.unlink(missing_ok=True)
|
|
154
|
+
return
|
|
155
|
+
if not isinstance(data, dict):
|
|
156
|
+
return
|
|
157
|
+
perm = data.get("permissions")
|
|
158
|
+
if isinstance(perm, dict) and perm.get("deny") == [] and set(data.keys()) == {"permissions"}:
|
|
159
|
+
if set(perm.keys()) <= {"deny"}:
|
|
160
|
+
path.unlink(missing_ok=True)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def finalize_claude_repo(target_root: Path) -> None:
|
|
164
|
+
"""Claude adapter: repo `.mcp.json`, `.claude/{skills,settings,...}`, drop `.claude` if vacant."""
|
|
165
|
+
claude_root_mcp_maybe_unlink(target_root)
|
|
166
|
+
settings = target_root / ".claude" / "settings.json"
|
|
167
|
+
claude_settings_maybe_unlink(settings)
|
|
168
|
+
finalize_standard_dotdir_skills_and_mcp(
|
|
169
|
+
target_root,
|
|
170
|
+
".claude",
|
|
171
|
+
allow_delete_entire=True,
|
|
172
|
+
unlink_settings_json_when_mcp_servers_empty=False,
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def finalize_codex_repo(target_root: Path) -> None:
|
|
177
|
+
from spawn_cli.io.toml_io import load_toml
|
|
178
|
+
|
|
179
|
+
agents = target_root / ".agents"
|
|
180
|
+
codex_cfg = target_root / ".codex" / "config.toml"
|
|
181
|
+
if codex_cfg.is_file():
|
|
182
|
+
data = load_toml(codex_cfg)
|
|
183
|
+
if isinstance(data, dict) and (
|
|
184
|
+
data == {} or (set(data.keys()) <= {"mcp_servers"} and not data.get("mcp_servers"))
|
|
185
|
+
):
|
|
186
|
+
codex_cfg.unlink(missing_ok=True)
|
|
187
|
+
prune_empty_directories_under(codex_cfg.parent)
|
|
188
|
+
prune_empty_directories_under(agents)
|
|
189
|
+
codex_root = target_root / ".codex"
|
|
190
|
+
try:
|
|
191
|
+
if codex_root.is_dir() and not any(codex_root.iterdir()):
|
|
192
|
+
codex_root.rmdir()
|
|
193
|
+
except OSError:
|
|
194
|
+
pass
|
|
195
|
+
if agents.is_dir() and not dir_has_any_file(agents):
|
|
196
|
+
shutil.rmtree(agents, ignore_errors=True)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def finalize_github_copilot_repo(target_root: Path) -> None:
|
|
200
|
+
try_unlink_empty_vscode_mcp(target_root / ".vscode" / "mcp.json")
|
|
201
|
+
prune_empty_directories_under(target_root / ".github" / "skills")
|
|
202
|
+
sk = target_root / ".github" / "skills"
|
|
203
|
+
try:
|
|
204
|
+
if sk.is_dir() and not any(sk.iterdir()):
|
|
205
|
+
sk.rmdir()
|
|
206
|
+
except OSError:
|
|
207
|
+
pass
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
__all__ = [
|
|
211
|
+
"dir_has_any_file",
|
|
212
|
+
"finalize_claude_repo",
|
|
213
|
+
"finalize_codex_repo",
|
|
214
|
+
"finalize_github_copilot_repo",
|
|
215
|
+
"finalize_standard_dotdir_skills_and_mcp",
|
|
216
|
+
"ide_dotdir_is_entirely_removable",
|
|
217
|
+
"json_value_nonempty",
|
|
218
|
+
"mcp_json_mcp_servers_format_is_empty",
|
|
219
|
+
"prune_empty_directories_under",
|
|
220
|
+
"vscode_servers_mcp_json_is_empty",
|
|
221
|
+
]
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""Anthropic Claude Code IDE adapter: .claude/skills, root .mcp.json, settings permissions, CLAUDE.md."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import warnings
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from spawn_cli.ide import _vacancy as _vac
|
|
10
|
+
from spawn_cli.ide.registry import (
|
|
11
|
+
DetectResult,
|
|
12
|
+
IdeAdapter,
|
|
13
|
+
IdeCapabilities,
|
|
14
|
+
register,
|
|
15
|
+
normalize_skill_name,
|
|
16
|
+
render_skill_md,
|
|
17
|
+
rewrite_managed_block,
|
|
18
|
+
)
|
|
19
|
+
from spawn_cli.models.mcp import McpServer, NormalizedMcp
|
|
20
|
+
from spawn_cli.models.skill import SkillMetadata
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _build_generic_mcp_entry(server: McpServer) -> dict:
|
|
24
|
+
transport = server.transport
|
|
25
|
+
if transport.type == "stdio":
|
|
26
|
+
entry: dict = {"command": transport.command, "args": list(transport.args)}
|
|
27
|
+
elif transport.type in ("streamable-http", "sse"):
|
|
28
|
+
entry = {"type": transport.type, "url": transport.url}
|
|
29
|
+
if transport.headers:
|
|
30
|
+
entry["headers"] = {k: f"${{{v}}}" for k, v in transport.headers.items()}
|
|
31
|
+
else:
|
|
32
|
+
entry = {"command": transport.command, "args": list(transport.args)}
|
|
33
|
+
if server.env:
|
|
34
|
+
entry["env"] = {
|
|
35
|
+
k: f"${{{k}}}" if v.secret else v.value or f"${{{k}}}"
|
|
36
|
+
for k, v in server.env.items()
|
|
37
|
+
}
|
|
38
|
+
return entry
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class ClaudeCodeAdapter(IdeAdapter):
|
|
42
|
+
key = "claude-code"
|
|
43
|
+
|
|
44
|
+
def detect(self, target_root: Path) -> DetectResult:
|
|
45
|
+
used = (target_root / ".claude").exists() or (target_root / "CLAUDE.md").exists()
|
|
46
|
+
return DetectResult(
|
|
47
|
+
used_in_repo=used,
|
|
48
|
+
capabilities=IdeCapabilities(
|
|
49
|
+
skills="native",
|
|
50
|
+
mcp="project",
|
|
51
|
+
agent_ignore="project",
|
|
52
|
+
entry_point="claude-md",
|
|
53
|
+
),
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
def add_skills(self, target_root: Path, skill_metadata: list[SkillMetadata]) -> list[dict]:
|
|
57
|
+
results = []
|
|
58
|
+
for skill in skill_metadata:
|
|
59
|
+
name = normalize_skill_name(skill.name)
|
|
60
|
+
skill_dir = target_root / ".claude" / "skills" / name
|
|
61
|
+
skill_dir.mkdir(parents=True, exist_ok=True)
|
|
62
|
+
skill_path = skill_dir / "SKILL.md"
|
|
63
|
+
if skill_path.exists():
|
|
64
|
+
warnings.warn(f"Overwriting existing rendered skill: {skill_path}")
|
|
65
|
+
skill_path.write_text(render_skill_md(skill), encoding="utf-8")
|
|
66
|
+
rel = skill_path.relative_to(target_root).as_posix()
|
|
67
|
+
results.append({"skill": skill.name, "path": rel})
|
|
68
|
+
return results
|
|
69
|
+
|
|
70
|
+
def remove_skills(self, target_root: Path, rendered_paths: list[dict]) -> None:
|
|
71
|
+
for entry in rendered_paths:
|
|
72
|
+
p = target_root / entry["path"]
|
|
73
|
+
if p.exists():
|
|
74
|
+
p.unlink()
|
|
75
|
+
if p.parent.exists() and not any(p.parent.iterdir()):
|
|
76
|
+
p.parent.rmdir()
|
|
77
|
+
|
|
78
|
+
def add_mcp(self, target_root: Path, normalized_mcp: NormalizedMcp) -> list[str]:
|
|
79
|
+
mcp_path = target_root / ".mcp.json"
|
|
80
|
+
data = json.loads(mcp_path.read_text(encoding="utf-8")) if mcp_path.exists() else {}
|
|
81
|
+
data.setdefault("mcpServers", {})
|
|
82
|
+
rendered_names = []
|
|
83
|
+
for server in normalized_mcp.servers:
|
|
84
|
+
data["mcpServers"][server.name] = _build_generic_mcp_entry(server)
|
|
85
|
+
rendered_names.append(server.name)
|
|
86
|
+
mcp_path.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
|
87
|
+
return rendered_names
|
|
88
|
+
|
|
89
|
+
def remove_mcp(self, target_root: Path, rendered_mcp_names: list[str]) -> None:
|
|
90
|
+
mcp_path = target_root / ".mcp.json"
|
|
91
|
+
if not mcp_path.exists():
|
|
92
|
+
return
|
|
93
|
+
data = json.loads(mcp_path.read_text(encoding="utf-8"))
|
|
94
|
+
for name in rendered_mcp_names:
|
|
95
|
+
data.get("mcpServers", {}).pop(name, None)
|
|
96
|
+
mcp_path.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
|
97
|
+
|
|
98
|
+
def add_agent_ignore(self, target_root: Path, globs: list[str]) -> None:
|
|
99
|
+
settings_path = target_root / ".claude" / "settings.json"
|
|
100
|
+
settings_path.parent.mkdir(parents=True, exist_ok=True)
|
|
101
|
+
data = json.loads(settings_path.read_text(encoding="utf-8")) if settings_path.exists() else {}
|
|
102
|
+
perms = data.setdefault("permissions", {})
|
|
103
|
+
deny = perms.setdefault("deny", [])
|
|
104
|
+
if not isinstance(deny, list):
|
|
105
|
+
deny_list: list = []
|
|
106
|
+
perms["deny"] = deny_list
|
|
107
|
+
else:
|
|
108
|
+
deny_list = deny
|
|
109
|
+
for glob in globs:
|
|
110
|
+
if glob not in deny_list:
|
|
111
|
+
deny_list.append(glob)
|
|
112
|
+
settings_path.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
|
113
|
+
|
|
114
|
+
def remove_agent_ignore(self, target_root: Path, globs: list[str]) -> None:
|
|
115
|
+
settings_path = target_root / ".claude" / "settings.json"
|
|
116
|
+
if not settings_path.exists():
|
|
117
|
+
return
|
|
118
|
+
data = json.loads(settings_path.read_text(encoding="utf-8"))
|
|
119
|
+
perms = data.get("permissions")
|
|
120
|
+
if not isinstance(perms, dict):
|
|
121
|
+
return
|
|
122
|
+
deny = perms.get("deny")
|
|
123
|
+
if not isinstance(deny, list):
|
|
124
|
+
return
|
|
125
|
+
drop = set(globs)
|
|
126
|
+
perms["deny"] = [g for g in deny if g not in drop]
|
|
127
|
+
settings_path.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
|
128
|
+
|
|
129
|
+
def rewrite_entry_point(self, target_root: Path, prompt: str) -> str:
|
|
130
|
+
ep = target_root / "CLAUDE.md"
|
|
131
|
+
if (target_root / ".claude" / "CLAUDE.md").exists():
|
|
132
|
+
warnings.warn(
|
|
133
|
+
"Claude Code: .claude/CLAUDE.md exists but Spawn writes to repo-root CLAUDE.md. "
|
|
134
|
+
"Update .claude/CLAUDE.md manually if needed."
|
|
135
|
+
)
|
|
136
|
+
rewrite_managed_block(ep, prompt)
|
|
137
|
+
return ep.relative_to(target_root).as_posix()
|
|
138
|
+
|
|
139
|
+
def finalize_repo_after_ide_removed(self, target_root: Path) -> None:
|
|
140
|
+
_vac.finalize_claude_repo(target_root)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
register(ClaudeCodeAdapter())
|
|
144
|
+
|
|
145
|
+
__all__ = ["ClaudeCodeAdapter"]
|
spawn_cli/ide/codex.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""OpenAI Codex IDE adapter: skills under .agents/, MCP in .codex/config.toml, AGENTS.md."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import warnings
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from spawn_cli.ide import _vacancy as _vac
|
|
9
|
+
from spawn_cli.ide.registry import (
|
|
10
|
+
DetectResult,
|
|
11
|
+
IdeAdapter,
|
|
12
|
+
IdeCapabilities,
|
|
13
|
+
register,
|
|
14
|
+
normalize_skill_name,
|
|
15
|
+
render_skill_md,
|
|
16
|
+
rewrite_managed_block,
|
|
17
|
+
)
|
|
18
|
+
from spawn_cli.io.toml_io import load_toml, save_toml
|
|
19
|
+
from spawn_cli.models.mcp import McpServer, NormalizedMcp
|
|
20
|
+
from spawn_cli.models.skill import SkillMetadata
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _build_toml_server_entry(server: McpServer) -> dict:
|
|
24
|
+
transport = server.transport
|
|
25
|
+
entry: dict = {}
|
|
26
|
+
if transport.type == "stdio":
|
|
27
|
+
entry["command"] = transport.command
|
|
28
|
+
entry["args"] = list(transport.args)
|
|
29
|
+
else:
|
|
30
|
+
entry["url"] = transport.url
|
|
31
|
+
if server.env:
|
|
32
|
+
entry["env"] = {
|
|
33
|
+
k: f"${{{k}}}" if v.secret else (v.value or f"${{{k}}}")
|
|
34
|
+
for k, v in server.env.items()
|
|
35
|
+
}
|
|
36
|
+
return entry
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class CodexAdapter(IdeAdapter):
|
|
40
|
+
key = "codex"
|
|
41
|
+
|
|
42
|
+
def detect(self, target_root: Path) -> DetectResult:
|
|
43
|
+
used = (target_root / ".codex").exists() or (target_root / ".agents").exists()
|
|
44
|
+
return DetectResult(
|
|
45
|
+
used_in_repo=used,
|
|
46
|
+
capabilities=IdeCapabilities(
|
|
47
|
+
skills="native",
|
|
48
|
+
mcp="project",
|
|
49
|
+
agent_ignore="unsupported",
|
|
50
|
+
entry_point="agents-md",
|
|
51
|
+
),
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
def add_skills(self, target_root: Path, skill_metadata: list[SkillMetadata]) -> list[dict]:
|
|
55
|
+
results = []
|
|
56
|
+
for skill in skill_metadata:
|
|
57
|
+
name = normalize_skill_name(skill.name)
|
|
58
|
+
skill_dir = target_root / ".agents" / "skills" / name
|
|
59
|
+
skill_dir.mkdir(parents=True, exist_ok=True)
|
|
60
|
+
skill_path = skill_dir / "SKILL.md"
|
|
61
|
+
if skill_path.exists():
|
|
62
|
+
warnings.warn(f"Overwriting existing rendered skill: {skill_path}")
|
|
63
|
+
skill_path.write_text(render_skill_md(skill), encoding="utf-8")
|
|
64
|
+
rel = skill_path.relative_to(target_root).as_posix()
|
|
65
|
+
results.append({"skill": skill.name, "path": rel})
|
|
66
|
+
return results
|
|
67
|
+
|
|
68
|
+
def remove_skills(self, target_root: Path, rendered_paths: list[dict]) -> None:
|
|
69
|
+
for entry in rendered_paths:
|
|
70
|
+
p = target_root / entry["path"]
|
|
71
|
+
if p.exists():
|
|
72
|
+
p.unlink()
|
|
73
|
+
if p.parent.exists() and not any(p.parent.iterdir()):
|
|
74
|
+
p.parent.rmdir()
|
|
75
|
+
|
|
76
|
+
def add_mcp(self, target_root: Path, normalized_mcp: NormalizedMcp) -> list[str]:
|
|
77
|
+
config_path = target_root / ".codex" / "config.toml"
|
|
78
|
+
data = load_toml(config_path)
|
|
79
|
+
mcp_servers = data.setdefault("mcp_servers", {})
|
|
80
|
+
if not isinstance(mcp_servers, dict):
|
|
81
|
+
mcp_servers = {}
|
|
82
|
+
data["mcp_servers"] = mcp_servers
|
|
83
|
+
rendered_names = []
|
|
84
|
+
for server in normalized_mcp.servers:
|
|
85
|
+
mcp_servers[server.name] = _build_toml_server_entry(server)
|
|
86
|
+
rendered_names.append(server.name)
|
|
87
|
+
save_toml(config_path, data)
|
|
88
|
+
return rendered_names
|
|
89
|
+
|
|
90
|
+
def remove_mcp(self, target_root: Path, rendered_mcp_names: list[str]) -> None:
|
|
91
|
+
config_path = target_root / ".codex" / "config.toml"
|
|
92
|
+
if not config_path.is_file():
|
|
93
|
+
return
|
|
94
|
+
data = load_toml(config_path)
|
|
95
|
+
mcp_servers = data.get("mcp_servers")
|
|
96
|
+
if isinstance(mcp_servers, dict):
|
|
97
|
+
for name in rendered_mcp_names:
|
|
98
|
+
mcp_servers.pop(name, None)
|
|
99
|
+
save_toml(config_path, data)
|
|
100
|
+
|
|
101
|
+
def add_agent_ignore(self, target_root: Path, globs: list[str]) -> None:
|
|
102
|
+
del target_root, globs
|
|
103
|
+
warnings.warn("codex: agent ignore is unsupported; steer via AGENTS.md policy instead")
|
|
104
|
+
|
|
105
|
+
def remove_agent_ignore(self, target_root: Path, globs: list[str]) -> None:
|
|
106
|
+
del target_root, globs
|
|
107
|
+
warnings.warn("codex: agent ignore is unsupported")
|
|
108
|
+
|
|
109
|
+
def rewrite_entry_point(self, target_root: Path, prompt: str) -> str:
|
|
110
|
+
ep = target_root / "AGENTS.md"
|
|
111
|
+
rewrite_managed_block(ep, prompt)
|
|
112
|
+
return ep.relative_to(target_root).as_posix()
|
|
113
|
+
|
|
114
|
+
def finalize_repo_after_ide_removed(self, target_root: Path) -> None:
|
|
115
|
+
_vac.finalize_codex_repo(target_root)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
register(CodexAdapter())
|
|
119
|
+
|
|
120
|
+
__all__ = ["CodexAdapter"]
|
spawn_cli/ide/cursor.py
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""Cursor IDE adapter: skills, MCP, agent ignore, AGENTS.md."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import warnings
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from spawn_cli.ide import _vacancy as _vac
|
|
10
|
+
from spawn_cli.ide.registry import (
|
|
11
|
+
DetectResult,
|
|
12
|
+
IdeAdapter,
|
|
13
|
+
IdeCapabilities,
|
|
14
|
+
register,
|
|
15
|
+
normalize_skill_name,
|
|
16
|
+
render_skill_md,
|
|
17
|
+
rewrite_managed_block,
|
|
18
|
+
rewrite_ignore_block,
|
|
19
|
+
remove_ignore_block,
|
|
20
|
+
)
|
|
21
|
+
from spawn_cli.models.mcp import McpServer, NormalizedMcp
|
|
22
|
+
from spawn_cli.models.skill import SkillMetadata
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _build_mcp_server_entry(server: McpServer) -> dict:
|
|
26
|
+
"""Convert McpServer to .cursor/mcp.json entry (mcpServers format)."""
|
|
27
|
+
transport = server.transport
|
|
28
|
+
if transport.type == "stdio":
|
|
29
|
+
entry: dict = {"command": transport.command, "args": transport.args}
|
|
30
|
+
elif transport.type in ("streamable-http", "sse"):
|
|
31
|
+
entry = {"type": transport.type, "url": transport.url}
|
|
32
|
+
if transport.headers:
|
|
33
|
+
entry["headers"] = {k: f"${{{v}}}" for k, v in transport.headers.items()}
|
|
34
|
+
else:
|
|
35
|
+
entry = {"command": transport.command, "args": transport.args}
|
|
36
|
+
if server.env:
|
|
37
|
+
entry["env"] = {
|
|
38
|
+
k: f"${{{k}}}" if v.secret else v.value or f"${{{k}}}"
|
|
39
|
+
for k, v in server.env.items()
|
|
40
|
+
}
|
|
41
|
+
return entry
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class CursorAdapter(IdeAdapter):
|
|
45
|
+
key = "cursor"
|
|
46
|
+
|
|
47
|
+
def detect(self, target_root: Path) -> DetectResult:
|
|
48
|
+
used = (target_root / ".cursor").exists()
|
|
49
|
+
return DetectResult(
|
|
50
|
+
used_in_repo=used,
|
|
51
|
+
capabilities=IdeCapabilities(
|
|
52
|
+
skills="native",
|
|
53
|
+
mcp="project",
|
|
54
|
+
agent_ignore="native",
|
|
55
|
+
entry_point="agents-md",
|
|
56
|
+
),
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
def add_skills(self, target_root: Path, skill_metadata: list[SkillMetadata]) -> list[dict]:
|
|
60
|
+
results = []
|
|
61
|
+
for skill in skill_metadata:
|
|
62
|
+
name = normalize_skill_name(skill.name)
|
|
63
|
+
skill_dir = target_root / ".cursor" / "skills" / name
|
|
64
|
+
skill_dir.mkdir(parents=True, exist_ok=True)
|
|
65
|
+
skill_path = skill_dir / "SKILL.md"
|
|
66
|
+
if skill_path.exists():
|
|
67
|
+
warnings.warn(f"Overwriting existing rendered skill: {skill_path}")
|
|
68
|
+
skill_path.write_text(render_skill_md(skill), encoding="utf-8")
|
|
69
|
+
rel = skill_path.relative_to(target_root).as_posix()
|
|
70
|
+
results.append({"skill": skill.name, "path": rel})
|
|
71
|
+
return results
|
|
72
|
+
|
|
73
|
+
def remove_skills(self, target_root: Path, rendered_paths: list[dict]) -> None:
|
|
74
|
+
for entry in rendered_paths:
|
|
75
|
+
p = target_root / entry["path"]
|
|
76
|
+
if p.exists():
|
|
77
|
+
p.unlink()
|
|
78
|
+
if p.parent.exists() and not any(p.parent.iterdir()):
|
|
79
|
+
p.parent.rmdir()
|
|
80
|
+
|
|
81
|
+
def add_mcp(self, target_root: Path, normalized_mcp: NormalizedMcp) -> list[str]:
|
|
82
|
+
"""Merge Spawn-owned servers into .cursor/mcp.json (mcpServers format)."""
|
|
83
|
+
mcp_path = target_root / ".cursor" / "mcp.json"
|
|
84
|
+
mcp_path.parent.mkdir(parents=True, exist_ok=True)
|
|
85
|
+
data = json.loads(mcp_path.read_text(encoding="utf-8")) if mcp_path.exists() else {}
|
|
86
|
+
data.setdefault("mcpServers", {})
|
|
87
|
+
rendered_names = []
|
|
88
|
+
for server in normalized_mcp.servers:
|
|
89
|
+
entry = _build_mcp_server_entry(server)
|
|
90
|
+
data["mcpServers"][server.name] = entry
|
|
91
|
+
rendered_names.append(server.name)
|
|
92
|
+
mcp_path.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
|
93
|
+
return rendered_names
|
|
94
|
+
|
|
95
|
+
def remove_mcp(self, target_root: Path, rendered_mcp_names: list[str]) -> None:
|
|
96
|
+
"""Remove named entries from .cursor/mcp.json."""
|
|
97
|
+
mcp_path = target_root / ".cursor" / "mcp.json"
|
|
98
|
+
if not mcp_path.exists():
|
|
99
|
+
return
|
|
100
|
+
data = json.loads(mcp_path.read_text(encoding="utf-8"))
|
|
101
|
+
for name in rendered_mcp_names:
|
|
102
|
+
data.get("mcpServers", {}).pop(name, None)
|
|
103
|
+
mcp_path.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
|
104
|
+
|
|
105
|
+
def add_agent_ignore(self, target_root: Path, globs: list[str]) -> None:
|
|
106
|
+
rewrite_ignore_block(target_root / ".cursorignore", globs)
|
|
107
|
+
|
|
108
|
+
def remove_agent_ignore(self, target_root: Path, globs: list[str]) -> None:
|
|
109
|
+
remove_ignore_block(target_root / ".cursorignore", globs)
|
|
110
|
+
|
|
111
|
+
def rewrite_entry_point(self, target_root: Path, prompt: str) -> str:
|
|
112
|
+
ep = target_root / "AGENTS.md"
|
|
113
|
+
rewrite_managed_block(ep, prompt)
|
|
114
|
+
return str(ep.relative_to(target_root))
|
|
115
|
+
|
|
116
|
+
def finalize_repo_after_ide_removed(self, target_root: Path) -> None:
|
|
117
|
+
_vac.finalize_standard_dotdir_skills_and_mcp(target_root, ".cursor", allow_delete_entire=True)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
register(CursorAdapter())
|
|
121
|
+
|
|
122
|
+
__all__ = ["CursorAdapter"]
|