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,130 @@
|
|
|
1
|
+
"""Google Gemini CLI IDE adapter: .gemini/skills, settings mcpServers, .geminiignore, GEMINI.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_gemini_mcp_entry(server: McpServer) -> dict:
|
|
26
|
+
transport = server.transport
|
|
27
|
+
entry: dict = {}
|
|
28
|
+
if transport.type == "stdio":
|
|
29
|
+
entry["command"] = transport.command
|
|
30
|
+
entry["args"] = list(transport.args)
|
|
31
|
+
elif transport.type == "streamable-http":
|
|
32
|
+
entry["httpUrl"] = transport.url or ""
|
|
33
|
+
if transport.headers:
|
|
34
|
+
entry["headers"] = {k: f"${{{v}}}" for k, v in transport.headers.items()}
|
|
35
|
+
elif transport.type == "sse":
|
|
36
|
+
entry["url"] = transport.url or ""
|
|
37
|
+
if transport.headers:
|
|
38
|
+
entry["headers"] = {k: f"${{{v}}}" for k, v in transport.headers.items()}
|
|
39
|
+
else:
|
|
40
|
+
entry["command"] = transport.command
|
|
41
|
+
entry["args"] = list(transport.args)
|
|
42
|
+
if server.env:
|
|
43
|
+
entry["env"] = {
|
|
44
|
+
k: f"${{{k}}}" if v.secret else (v.value or f"${{{k}}}")
|
|
45
|
+
for k, v in server.env.items()
|
|
46
|
+
}
|
|
47
|
+
return entry
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class GeminiCliAdapter(IdeAdapter):
|
|
51
|
+
key = "gemini-cli"
|
|
52
|
+
|
|
53
|
+
def detect(self, target_root: Path) -> DetectResult:
|
|
54
|
+
used = (target_root / ".gemini").exists() or (target_root / "GEMINI.md").exists()
|
|
55
|
+
return DetectResult(
|
|
56
|
+
used_in_repo=used,
|
|
57
|
+
capabilities=IdeCapabilities(
|
|
58
|
+
skills="native",
|
|
59
|
+
mcp="project",
|
|
60
|
+
agent_ignore="native",
|
|
61
|
+
entry_point="gemini-md",
|
|
62
|
+
),
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
def add_skills(self, target_root: Path, skill_metadata: list[SkillMetadata]) -> list[dict]:
|
|
66
|
+
results = []
|
|
67
|
+
for skill in skill_metadata:
|
|
68
|
+
name = normalize_skill_name(skill.name)
|
|
69
|
+
skill_dir = target_root / ".gemini" / "skills" / name
|
|
70
|
+
skill_dir.mkdir(parents=True, exist_ok=True)
|
|
71
|
+
skill_path = skill_dir / "SKILL.md"
|
|
72
|
+
if skill_path.exists():
|
|
73
|
+
warnings.warn(f"Overwriting existing rendered skill: {skill_path}")
|
|
74
|
+
skill_path.write_text(render_skill_md(skill), encoding="utf-8")
|
|
75
|
+
rel = skill_path.relative_to(target_root).as_posix()
|
|
76
|
+
results.append({"skill": skill.name, "path": rel})
|
|
77
|
+
return results
|
|
78
|
+
|
|
79
|
+
def remove_skills(self, target_root: Path, rendered_paths: list[dict]) -> None:
|
|
80
|
+
for entry in rendered_paths:
|
|
81
|
+
p = target_root / entry["path"]
|
|
82
|
+
if p.exists():
|
|
83
|
+
p.unlink()
|
|
84
|
+
if p.parent.exists() and not any(p.parent.iterdir()):
|
|
85
|
+
p.parent.rmdir()
|
|
86
|
+
|
|
87
|
+
def add_mcp(self, target_root: Path, normalized_mcp: NormalizedMcp) -> list[str]:
|
|
88
|
+
settings_path = target_root / ".gemini" / "settings.json"
|
|
89
|
+
settings_path.parent.mkdir(parents=True, exist_ok=True)
|
|
90
|
+
data = json.loads(settings_path.read_text(encoding="utf-8")) if settings_path.exists() else {}
|
|
91
|
+
data.setdefault("mcpServers", {})
|
|
92
|
+
rendered_names = []
|
|
93
|
+
for server in normalized_mcp.servers:
|
|
94
|
+
data["mcpServers"][server.name] = _build_gemini_mcp_entry(server)
|
|
95
|
+
rendered_names.append(server.name)
|
|
96
|
+
settings_path.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
|
97
|
+
return rendered_names
|
|
98
|
+
|
|
99
|
+
def remove_mcp(self, target_root: Path, rendered_mcp_names: list[str]) -> None:
|
|
100
|
+
settings_path = target_root / ".gemini" / "settings.json"
|
|
101
|
+
if not settings_path.exists():
|
|
102
|
+
return
|
|
103
|
+
data = json.loads(settings_path.read_text(encoding="utf-8"))
|
|
104
|
+
for name in rendered_mcp_names:
|
|
105
|
+
data.get("mcpServers", {}).pop(name, None)
|
|
106
|
+
settings_path.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
|
107
|
+
|
|
108
|
+
def add_agent_ignore(self, target_root: Path, globs: list[str]) -> None:
|
|
109
|
+
rewrite_ignore_block(target_root / ".geminiignore", globs)
|
|
110
|
+
|
|
111
|
+
def remove_agent_ignore(self, target_root: Path, globs: list[str]) -> None:
|
|
112
|
+
remove_ignore_block(target_root / ".geminiignore", globs)
|
|
113
|
+
|
|
114
|
+
def rewrite_entry_point(self, target_root: Path, prompt: str) -> str:
|
|
115
|
+
ep = target_root / "GEMINI.md"
|
|
116
|
+
rewrite_managed_block(ep, prompt)
|
|
117
|
+
return ep.relative_to(target_root).as_posix()
|
|
118
|
+
|
|
119
|
+
def finalize_repo_after_ide_removed(self, target_root: Path) -> None:
|
|
120
|
+
_vac.finalize_standard_dotdir_skills_and_mcp(
|
|
121
|
+
target_root,
|
|
122
|
+
".gemini",
|
|
123
|
+
allow_delete_entire=True,
|
|
124
|
+
unlink_settings_json_when_mcp_servers_empty=True,
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
register(GeminiCliAdapter())
|
|
129
|
+
|
|
130
|
+
__all__ = ["GeminiCliAdapter"]
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""GitHub Copilot IDE adapter: .github/skills, .vscode/mcp.json (servers + inputs), dual entry points."""
|
|
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 _env_input_id(server_name: str, var_name: str) -> str:
|
|
24
|
+
return f"{server_name}-{var_name.lower().replace('_', '-')}"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _build_vscode_mcp_entry(server: McpServer) -> tuple[dict, list[dict]]:
|
|
28
|
+
transport = server.transport
|
|
29
|
+
entry: dict = {}
|
|
30
|
+
inputs: list[dict] = []
|
|
31
|
+
|
|
32
|
+
if transport.type == "stdio":
|
|
33
|
+
entry["type"] = "stdio"
|
|
34
|
+
entry["command"] = transport.command
|
|
35
|
+
entry["args"] = list(transport.args)
|
|
36
|
+
else:
|
|
37
|
+
entry["type"] = transport.type
|
|
38
|
+
entry["url"] = transport.url
|
|
39
|
+
|
|
40
|
+
if server.env:
|
|
41
|
+
env_dict: dict[str, str] = {}
|
|
42
|
+
for var_name, var in server.env.items():
|
|
43
|
+
if var.secret:
|
|
44
|
+
input_id = _env_input_id(server.name, var_name)
|
|
45
|
+
env_dict[var_name] = f"${{input:{input_id}}}"
|
|
46
|
+
inputs.append(
|
|
47
|
+
{
|
|
48
|
+
"id": input_id,
|
|
49
|
+
"type": "promptString",
|
|
50
|
+
"description": var_name,
|
|
51
|
+
"password": True,
|
|
52
|
+
}
|
|
53
|
+
)
|
|
54
|
+
else:
|
|
55
|
+
env_dict[var_name] = var.value or f"${{{var_name}}}"
|
|
56
|
+
entry["env"] = env_dict
|
|
57
|
+
|
|
58
|
+
return entry, inputs
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _input_belongs_to_server(inp_id: str, server_name: str) -> bool:
|
|
62
|
+
prefix = f"{server_name}-"
|
|
63
|
+
return inp_id.startswith(prefix)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class GitHubCopilotAdapter(IdeAdapter):
|
|
67
|
+
key = "github-copilot"
|
|
68
|
+
|
|
69
|
+
def detect(self, target_root: Path) -> DetectResult:
|
|
70
|
+
used = (target_root / ".github").exists() or (target_root / ".vscode").exists()
|
|
71
|
+
return DetectResult(
|
|
72
|
+
used_in_repo=used,
|
|
73
|
+
capabilities=IdeCapabilities(
|
|
74
|
+
skills="native",
|
|
75
|
+
mcp="project",
|
|
76
|
+
agent_ignore="unsupported",
|
|
77
|
+
entry_point="copilot-instructions",
|
|
78
|
+
),
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
def add_skills(self, target_root: Path, skill_metadata: list[SkillMetadata]) -> list[dict]:
|
|
82
|
+
results = []
|
|
83
|
+
for skill in skill_metadata:
|
|
84
|
+
name = normalize_skill_name(skill.name)
|
|
85
|
+
skill_dir = target_root / ".github" / "skills" / name
|
|
86
|
+
skill_dir.mkdir(parents=True, exist_ok=True)
|
|
87
|
+
skill_path = skill_dir / "SKILL.md"
|
|
88
|
+
if skill_path.exists():
|
|
89
|
+
warnings.warn(f"Overwriting existing rendered skill: {skill_path}")
|
|
90
|
+
skill_path.write_text(render_skill_md(skill), encoding="utf-8")
|
|
91
|
+
rel = skill_path.relative_to(target_root).as_posix()
|
|
92
|
+
results.append({"skill": skill.name, "path": rel})
|
|
93
|
+
return results
|
|
94
|
+
|
|
95
|
+
def remove_skills(self, target_root: Path, rendered_paths: list[dict]) -> None:
|
|
96
|
+
for entry in rendered_paths:
|
|
97
|
+
p = target_root / entry["path"]
|
|
98
|
+
if p.exists():
|
|
99
|
+
p.unlink()
|
|
100
|
+
if p.parent.exists() and not any(p.parent.iterdir()):
|
|
101
|
+
p.parent.rmdir()
|
|
102
|
+
|
|
103
|
+
def add_mcp(self, target_root: Path, normalized_mcp: NormalizedMcp) -> list[str]:
|
|
104
|
+
mcp_path = target_root / ".vscode" / "mcp.json"
|
|
105
|
+
mcp_path.parent.mkdir(parents=True, exist_ok=True)
|
|
106
|
+
data = json.loads(mcp_path.read_text(encoding="utf-8")) if mcp_path.exists() else {}
|
|
107
|
+
data.setdefault("servers", {})
|
|
108
|
+
data.setdefault("inputs", [])
|
|
109
|
+
if not isinstance(data["inputs"], list):
|
|
110
|
+
data["inputs"] = []
|
|
111
|
+
rendered_names = []
|
|
112
|
+
existing_ids = {inp["id"] for inp in data["inputs"] if isinstance(inp, dict) and "id" in inp}
|
|
113
|
+
for server in normalized_mcp.servers:
|
|
114
|
+
entry, new_inputs = _build_vscode_mcp_entry(server)
|
|
115
|
+
data["servers"][server.name] = entry
|
|
116
|
+
for inp in new_inputs:
|
|
117
|
+
if inp["id"] not in existing_ids:
|
|
118
|
+
data["inputs"].append(inp)
|
|
119
|
+
existing_ids.add(inp["id"])
|
|
120
|
+
rendered_names.append(server.name)
|
|
121
|
+
mcp_path.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
|
122
|
+
return rendered_names
|
|
123
|
+
|
|
124
|
+
def remove_mcp(self, target_root: Path, rendered_mcp_names: list[str]) -> None:
|
|
125
|
+
mcp_path = target_root / ".vscode" / "mcp.json"
|
|
126
|
+
if not mcp_path.exists():
|
|
127
|
+
return
|
|
128
|
+
data = json.loads(mcp_path.read_text(encoding="utf-8"))
|
|
129
|
+
for name in rendered_mcp_names:
|
|
130
|
+
data.get("servers", {}).pop(name, None)
|
|
131
|
+
removed = set(rendered_mcp_names)
|
|
132
|
+
inputs_list = data.get("inputs", [])
|
|
133
|
+
if isinstance(inputs_list, list):
|
|
134
|
+
data["inputs"] = [
|
|
135
|
+
inp
|
|
136
|
+
for inp in inputs_list
|
|
137
|
+
if not isinstance(inp, dict)
|
|
138
|
+
or "id" not in inp
|
|
139
|
+
or not any(_input_belongs_to_server(str(inp["id"]), n) for n in removed)
|
|
140
|
+
]
|
|
141
|
+
mcp_path.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
|
142
|
+
|
|
143
|
+
def add_agent_ignore(self, target_root: Path, globs: list[str]) -> None:
|
|
144
|
+
del target_root, globs
|
|
145
|
+
warnings.warn(
|
|
146
|
+
"github-copilot: agent ignore is unsupported; "
|
|
147
|
+
"GitHub Copilot content exclusion does not apply to IDE Agent mode"
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
def remove_agent_ignore(self, target_root: Path, globs: list[str]) -> None:
|
|
151
|
+
del target_root, globs
|
|
152
|
+
warnings.warn("github-copilot: agent ignore is unsupported")
|
|
153
|
+
|
|
154
|
+
def rewrite_entry_point(self, target_root: Path, prompt: str) -> str:
|
|
155
|
+
ep1 = target_root / ".github" / "copilot-instructions.md"
|
|
156
|
+
ep2 = target_root / "AGENTS.md"
|
|
157
|
+
rewrite_managed_block(ep1, prompt)
|
|
158
|
+
rewrite_managed_block(ep2, prompt)
|
|
159
|
+
return ep1.relative_to(target_root).as_posix()
|
|
160
|
+
|
|
161
|
+
def finalize_repo_after_ide_removed(self, target_root: Path) -> None:
|
|
162
|
+
_vac.finalize_github_copilot_repo(target_root)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
register(GitHubCopilotAdapter())
|
|
166
|
+
|
|
167
|
+
__all__ = ["GitHubCopilotAdapter"]
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""IDE adapter registry and abstract adapter interface."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from spawn_cli.core.errors import SpawnError
|
|
10
|
+
from spawn_cli.core.low_level import CANONICAL_IDE_KEYS
|
|
11
|
+
from spawn_cli.ide._helpers import (
|
|
12
|
+
IGNORE_BLOCK_END,
|
|
13
|
+
IGNORE_BLOCK_START,
|
|
14
|
+
SPAWN_BLOCK_END,
|
|
15
|
+
SPAWN_BLOCK_START,
|
|
16
|
+
normalize_skill_name,
|
|
17
|
+
remove_ignore_block,
|
|
18
|
+
render_skill_md,
|
|
19
|
+
rewrite_ignore_block,
|
|
20
|
+
rewrite_managed_block,
|
|
21
|
+
)
|
|
22
|
+
from spawn_cli.models.mcp import NormalizedMcp
|
|
23
|
+
from spawn_cli.models.skill import SkillMetadata
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class IdeCapabilities:
|
|
28
|
+
skills: str # native | project | entry-only | external | unsupported
|
|
29
|
+
mcp: str # native | project | entry-only | external | unsupported
|
|
30
|
+
agent_ignore: str # native | project | entry-only | external | unsupported
|
|
31
|
+
entry_point: str # native | project | entry-only | external | unsupported
|
|
32
|
+
|
|
33
|
+
def to_dict(self) -> dict:
|
|
34
|
+
"""Serialize to camelCase dict for YAML output."""
|
|
35
|
+
return {
|
|
36
|
+
"skills": self.skills,
|
|
37
|
+
"mcp": self.mcp,
|
|
38
|
+
"agentIgnore": self.agent_ignore,
|
|
39
|
+
"entryPoint": self.entry_point,
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass
|
|
44
|
+
class DetectResult:
|
|
45
|
+
used_in_repo: bool
|
|
46
|
+
capabilities: IdeCapabilities
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class IdeAdapter(ABC):
|
|
50
|
+
key: str # canonical key, defined on the class or instance
|
|
51
|
+
|
|
52
|
+
@abstractmethod
|
|
53
|
+
def detect(self, target_root: Path) -> DetectResult: ...
|
|
54
|
+
|
|
55
|
+
@abstractmethod
|
|
56
|
+
def add_skills(self, target_root: Path, skill_metadata: list[SkillMetadata]) -> list[dict]:
|
|
57
|
+
"""Returns list of {skill: str, path: str} for ownership tracking."""
|
|
58
|
+
...
|
|
59
|
+
|
|
60
|
+
@abstractmethod
|
|
61
|
+
def remove_skills(self, target_root: Path, rendered_paths: list[dict]) -> None: ...
|
|
62
|
+
|
|
63
|
+
@abstractmethod
|
|
64
|
+
def add_mcp(self, target_root: Path, normalized_mcp: NormalizedMcp) -> list[str]:
|
|
65
|
+
"""Returns list of rendered server names."""
|
|
66
|
+
...
|
|
67
|
+
|
|
68
|
+
@abstractmethod
|
|
69
|
+
def remove_mcp(self, target_root: Path, rendered_mcp_names: list[str]) -> None: ...
|
|
70
|
+
|
|
71
|
+
@abstractmethod
|
|
72
|
+
def add_agent_ignore(self, target_root: Path, globs: list[str]) -> None: ...
|
|
73
|
+
|
|
74
|
+
@abstractmethod
|
|
75
|
+
def remove_agent_ignore(self, target_root: Path, globs: list[str]) -> None: ...
|
|
76
|
+
|
|
77
|
+
@abstractmethod
|
|
78
|
+
def rewrite_entry_point(self, target_root: Path, prompt: str) -> str:
|
|
79
|
+
"""Returns rendered path or warning string."""
|
|
80
|
+
...
|
|
81
|
+
|
|
82
|
+
def finalize_repo_after_ide_removed(self, target_root: Path) -> None:
|
|
83
|
+
"""Vacancy-prune Spawn-owned repo-root directories after MCP/skills/ignore teardown."""
|
|
84
|
+
del target_root
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
ALIASES: dict[str, str] = {
|
|
88
|
+
"claude": "claude-code",
|
|
89
|
+
"qwen": "qwen-code",
|
|
90
|
+
"gemini": "gemini-cli",
|
|
91
|
+
"copilot": "github-copilot",
|
|
92
|
+
"github": "github-copilot",
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
_registry: dict[str, IdeAdapter] = {} # populated at module load by adapter modules
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def register(adapter: IdeAdapter) -> None:
|
|
99
|
+
"""Register an adapter instance under its canonical key (must appear in CANONICAL_IDE_KEYS)."""
|
|
100
|
+
key = getattr(adapter, "key", None)
|
|
101
|
+
if key not in CANONICAL_IDE_KEYS:
|
|
102
|
+
raise SpawnError(f"Cannot register unknown IDE key: {key!r}")
|
|
103
|
+
_registry[key] = adapter
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def get(name: str) -> IdeAdapter:
|
|
107
|
+
"""Resolve alias to canonical key to adapter. SpawnError on unknown."""
|
|
108
|
+
canonical = ALIASES.get(name, name)
|
|
109
|
+
if canonical not in _registry:
|
|
110
|
+
raise SpawnError(f"Unknown IDE: {name!r}")
|
|
111
|
+
return _registry[canonical]
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def supported_ide_keys() -> list[str]:
|
|
115
|
+
"""Return the frozen ordered list from low_level (not dict iteration order)."""
|
|
116
|
+
return list(CANONICAL_IDE_KEYS)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def detect_supported_ides(target_root: Path) -> dict[str, DetectResult]:
|
|
120
|
+
"""Run detect() for every canonical IDE key in CANONICAL_IDE_KEYS order."""
|
|
121
|
+
return {key: get(key).detect(target_root) for key in CANONICAL_IDE_KEYS}
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
__all__ = [
|
|
125
|
+
"ALIASES",
|
|
126
|
+
"DetectResult",
|
|
127
|
+
"IdeAdapter",
|
|
128
|
+
"IdeCapabilities",
|
|
129
|
+
"IGNORE_BLOCK_END",
|
|
130
|
+
"IGNORE_BLOCK_START",
|
|
131
|
+
"SPAWN_BLOCK_END",
|
|
132
|
+
"SPAWN_BLOCK_START",
|
|
133
|
+
"detect_supported_ides",
|
|
134
|
+
"get",
|
|
135
|
+
"normalize_skill_name",
|
|
136
|
+
"register",
|
|
137
|
+
"remove_ignore_block",
|
|
138
|
+
"render_skill_md",
|
|
139
|
+
"rewrite_ignore_block",
|
|
140
|
+
"rewrite_managed_block",
|
|
141
|
+
"supported_ide_keys",
|
|
142
|
+
]
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Windsurf IDE adapter: .windsurf/skills, .codeiumignore, AGENTS.md; MCP unsupported."""
|
|
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
|
+
rewrite_ignore_block,
|
|
18
|
+
remove_ignore_block,
|
|
19
|
+
)
|
|
20
|
+
from spawn_cli.models.mcp import NormalizedMcp
|
|
21
|
+
from spawn_cli.models.skill import SkillMetadata
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class WindsurfAdapter(IdeAdapter):
|
|
25
|
+
key = "windsurf"
|
|
26
|
+
|
|
27
|
+
def detect(self, target_root: Path) -> DetectResult:
|
|
28
|
+
used = (target_root / ".windsurf").exists() or (target_root / ".codeiumignore").exists()
|
|
29
|
+
return DetectResult(
|
|
30
|
+
used_in_repo=used,
|
|
31
|
+
capabilities=IdeCapabilities(
|
|
32
|
+
skills="native",
|
|
33
|
+
mcp="unsupported",
|
|
34
|
+
agent_ignore="native",
|
|
35
|
+
entry_point="agents-md",
|
|
36
|
+
),
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
def add_skills(self, target_root: Path, skill_metadata: list[SkillMetadata]) -> list[dict]:
|
|
40
|
+
results = []
|
|
41
|
+
for skill in skill_metadata:
|
|
42
|
+
name = normalize_skill_name(skill.name)
|
|
43
|
+
skill_dir = target_root / ".windsurf" / "skills" / name
|
|
44
|
+
skill_dir.mkdir(parents=True, exist_ok=True)
|
|
45
|
+
skill_path = skill_dir / "SKILL.md"
|
|
46
|
+
if skill_path.exists():
|
|
47
|
+
warnings.warn(f"Overwriting existing rendered skill: {skill_path}")
|
|
48
|
+
skill_path.write_text(render_skill_md(skill), encoding="utf-8")
|
|
49
|
+
rel = skill_path.relative_to(target_root).as_posix()
|
|
50
|
+
results.append({"skill": skill.name, "path": rel})
|
|
51
|
+
return results
|
|
52
|
+
|
|
53
|
+
def remove_skills(self, target_root: Path, rendered_paths: list[dict]) -> None:
|
|
54
|
+
for entry in rendered_paths:
|
|
55
|
+
p = target_root / entry["path"]
|
|
56
|
+
if p.exists():
|
|
57
|
+
p.unlink()
|
|
58
|
+
if p.parent.exists() and not any(p.parent.iterdir()):
|
|
59
|
+
p.parent.rmdir()
|
|
60
|
+
|
|
61
|
+
def add_mcp(self, target_root: Path, normalized_mcp: NormalizedMcp) -> list[str]:
|
|
62
|
+
del normalized_mcp
|
|
63
|
+
del target_root
|
|
64
|
+
warnings.warn(
|
|
65
|
+
"windsurf: Windsurf does not support project MCP config; "
|
|
66
|
+
"MCP is configured globally in Cascade"
|
|
67
|
+
)
|
|
68
|
+
return []
|
|
69
|
+
|
|
70
|
+
def remove_mcp(self, target_root: Path, rendered_mcp_names: list[str]) -> None:
|
|
71
|
+
del target_root, rendered_mcp_names
|
|
72
|
+
warnings.warn("windsurf: MCP is unsupported; nothing to remove")
|
|
73
|
+
|
|
74
|
+
def add_agent_ignore(self, target_root: Path, globs: list[str]) -> None:
|
|
75
|
+
rewrite_ignore_block(target_root / ".codeiumignore", globs)
|
|
76
|
+
|
|
77
|
+
def remove_agent_ignore(self, target_root: Path, globs: list[str]) -> None:
|
|
78
|
+
remove_ignore_block(target_root / ".codeiumignore", globs)
|
|
79
|
+
|
|
80
|
+
def rewrite_entry_point(self, target_root: Path, prompt: str) -> str:
|
|
81
|
+
ep = target_root / "AGENTS.md"
|
|
82
|
+
rewrite_managed_block(ep, prompt)
|
|
83
|
+
return ep.relative_to(target_root).as_posix()
|
|
84
|
+
|
|
85
|
+
def finalize_repo_after_ide_removed(self, target_root: Path) -> None:
|
|
86
|
+
_vac.finalize_standard_dotdir_skills_and_mcp(target_root, ".windsurf", allow_delete_entire=True)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
register(WindsurfAdapter())
|
|
90
|
+
|
|
91
|
+
__all__ = ["WindsurfAdapter"]
|
spawn_cli/io/__init__.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from spawn_cli.io.json_io import load_json, save_json
|
|
2
|
+
from spawn_cli.io.lock import spawn_lock
|
|
3
|
+
from spawn_cli.io.paths import ensure_dir, safe_path, spawn_root
|
|
4
|
+
from spawn_cli.io.text_io import read_lines, write_lines
|
|
5
|
+
from spawn_cli.io.toml_io import load_toml, save_toml
|
|
6
|
+
from spawn_cli.io.yaml_io import load_yaml, save_yaml
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"ensure_dir",
|
|
10
|
+
"load_json",
|
|
11
|
+
"load_toml",
|
|
12
|
+
"load_yaml",
|
|
13
|
+
"read_lines",
|
|
14
|
+
"safe_path",
|
|
15
|
+
"save_json",
|
|
16
|
+
"save_toml",
|
|
17
|
+
"save_yaml",
|
|
18
|
+
"spawn_lock",
|
|
19
|
+
"spawn_root",
|
|
20
|
+
"write_lines",
|
|
21
|
+
]
|
spawn_cli/io/json_io.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from spawn_cli.io.paths import ensure_dir
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def load_json(path: Path) -> dict:
|
|
10
|
+
if not path.is_file():
|
|
11
|
+
return {}
|
|
12
|
+
raw = path.read_text(encoding="utf-8")
|
|
13
|
+
if not raw.strip():
|
|
14
|
+
return {}
|
|
15
|
+
data = json.loads(raw)
|
|
16
|
+
return data if isinstance(data, dict) else {}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def save_json(path: Path, data: dict, indent: int = 2) -> None:
|
|
20
|
+
ensure_dir(path.parent)
|
|
21
|
+
path.write_text(json.dumps(data, indent=indent, ensure_ascii=False) + "\n", encoding="utf-8")
|
spawn_cli/io/lock.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from contextlib import contextmanager
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from filelock import FileLock, Timeout
|
|
7
|
+
|
|
8
|
+
from spawn_cli.errors import SpawnError
|
|
9
|
+
from spawn_cli.io.paths import ensure_dir
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@contextmanager
|
|
13
|
+
def spawn_lock(target_root: Path):
|
|
14
|
+
lock_path = target_root / "spawn" / ".metadata" / ".spawn.lock"
|
|
15
|
+
ensure_dir(lock_path.parent)
|
|
16
|
+
lock = FileLock(str(lock_path))
|
|
17
|
+
try:
|
|
18
|
+
lock.acquire(timeout=0)
|
|
19
|
+
except Timeout:
|
|
20
|
+
raise SpawnError("Операция в процессе (файл lock detected)") from None
|
|
21
|
+
try:
|
|
22
|
+
yield
|
|
23
|
+
finally:
|
|
24
|
+
lock.release()
|
spawn_cli/io/paths.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from spawn_cli.errors import SpawnError
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def ensure_dir(path: Path) -> None:
|
|
9
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def safe_path(root: Path, rel: str) -> Path:
|
|
13
|
+
root_resolved = root.resolve()
|
|
14
|
+
candidate = (root_resolved / rel).resolve()
|
|
15
|
+
try:
|
|
16
|
+
candidate.relative_to(root_resolved)
|
|
17
|
+
except ValueError:
|
|
18
|
+
raise SpawnError(f"path escapes repository root: {rel!r}") from None
|
|
19
|
+
return candidate
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def spawn_root(target: Path) -> Path:
|
|
23
|
+
return target / "spawn"
|
spawn_cli/io/text_io.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from spawn_cli.io.paths import ensure_dir
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def read_lines(path: Path) -> list[str]:
|
|
9
|
+
if not path.is_file():
|
|
10
|
+
return []
|
|
11
|
+
return path.read_text(encoding="utf-8").splitlines()
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def write_lines(path: Path, lines: list[str]) -> None:
|
|
15
|
+
ensure_dir(path.parent)
|
|
16
|
+
path.write_text("\n".join(lines) + ("\n" if lines else ""), encoding="utf-8")
|
spawn_cli/io/toml_io.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from spawn_cli.io.paths import ensure_dir
|
|
7
|
+
|
|
8
|
+
if sys.version_info >= (3, 11):
|
|
9
|
+
import tomllib
|
|
10
|
+
else:
|
|
11
|
+
import tomli as tomllib
|
|
12
|
+
|
|
13
|
+
import tomli_w
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def load_toml(path: Path) -> dict:
|
|
17
|
+
if not path.is_file():
|
|
18
|
+
return {}
|
|
19
|
+
raw = path.read_bytes()
|
|
20
|
+
data = tomllib.loads(raw.decode("utf-8"))
|
|
21
|
+
return data if isinstance(data, dict) else {}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def save_toml(path: Path, data: dict) -> None:
|
|
25
|
+
ensure_dir(path.parent)
|
|
26
|
+
path.write_bytes(tomli_w.dumps(data).encode("utf-8"))
|