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.
@@ -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"]
@@ -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
+ ]
@@ -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"
@@ -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")
@@ -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"))