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,246 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import shutil
5
+ import subprocess
6
+ import sys
7
+ import tempfile
8
+ import warnings
9
+ from dataclasses import dataclass
10
+ from pathlib import Path
11
+
12
+ from spawn_cli.core.errors import SpawnError, SpawnWarning
13
+ from spawn_cli.io.yaml_io import load_yaml
14
+ from spawn_cli.models.config import ExtensionConfig
15
+ from spawn_cli.models.config import SourceYaml as SY
16
+
17
+ SCRIPT_ENV_VARS = (
18
+ "SPAWN_EXT_NAME",
19
+ "SPAWN_EXT_PATH",
20
+ "SPAWN_EXT_VERSION",
21
+ "SPAWN_TARGET_VERSION",
22
+ "SPAWN_TARGET_ROOT",
23
+ )
24
+
25
+
26
+ def _extend_dir(target_root: Path, extension: str) -> Path:
27
+ return target_root / "spawn" / ".extend" / extension
28
+
29
+
30
+ def _ext_layout_root(target_root: Path, extension: str, override: Path | None) -> Path:
31
+ if override is not None:
32
+ return override
33
+ return _extend_dir(target_root, extension)
34
+
35
+
36
+ def _core_version(target_root: Path) -> str:
37
+ raw = load_yaml(target_root / "spawn" / ".core" / "config.yaml")
38
+ if not raw or not raw.get("version"):
39
+ return "0.0.0"
40
+ return str(raw["version"])
41
+
42
+
43
+ def _setup_script_path(ext_layout: Path, setup_filename: str) -> Path:
44
+ return ext_layout / "setup" / setup_filename
45
+
46
+
47
+ def _extension_config_at(ext_layout: Path) -> ExtensionConfig:
48
+ raw = load_yaml(ext_layout / "config.yaml")
49
+ if not raw:
50
+ raise SpawnError("missing extension config.yaml for script run")
51
+ return ExtensionConfig.model_validate(raw)
52
+
53
+
54
+ def _installed_source_version(target_root: Path, extension: str) -> str | None:
55
+ p = _extend_dir(target_root, extension) / "source.yaml"
56
+ if not p.is_file():
57
+ return None
58
+ raw = load_yaml(p)
59
+ if not raw:
60
+ return None
61
+ sy = SY.model_validate(raw)
62
+ return sy.installed.version
63
+
64
+
65
+ def _run_script(
66
+ target_root: Path,
67
+ extension: str,
68
+ script_filename: str,
69
+ *,
70
+ ext_layout: Path,
71
+ blocking: bool,
72
+ ) -> subprocess.CompletedProcess[str]:
73
+ script_path = _setup_script_path(ext_layout, script_filename)
74
+ if not script_path.is_file():
75
+ raise SpawnError(f"setup script missing: {script_path}")
76
+ cfg = _extension_config_at(ext_layout)
77
+ ext_ver = cfg.version
78
+ tgt_ver = _core_version(target_root)
79
+ src_ver = _installed_source_version(target_root, extension) or ext_ver
80
+ env = os.environ.copy()
81
+ env["SPAWN_TARGET_ROOT"] = str(target_root.resolve())
82
+ env["SPAWN_EXT_NAME"] = extension
83
+ env["SPAWN_EXT_PATH"] = str(ext_layout.resolve())
84
+ env["SPAWN_EXT_VERSION"] = ext_ver
85
+ env["SPAWN_TARGET_VERSION"] = tgt_ver
86
+ proc = subprocess.run(
87
+ [sys.executable, str(script_path)],
88
+ cwd=str(target_root),
89
+ env=env,
90
+ capture_output=True,
91
+ text=True,
92
+ check=False,
93
+ )
94
+ if proc.returncode != 0:
95
+ msg = f"Setup script {script_filename!r} exited with status {proc.returncode}"
96
+ if proc.stderr.strip():
97
+ msg = f"{msg}: {proc.stderr.strip()}"
98
+ if blocking:
99
+ raise SpawnError(msg)
100
+ warnings.warn(msg, SpawnWarning)
101
+ return proc
102
+
103
+
104
+ def run_before_install_scripts(target_root: Path, extension: str, *, ext_layout: Path | None = None) -> None:
105
+ layout = _ext_layout_root(target_root, extension, ext_layout)
106
+ cfg = _extension_config_at(layout)
107
+ if not cfg.setup or not cfg.setup.before_install:
108
+ return
109
+ _run_script(
110
+ target_root,
111
+ extension,
112
+ cfg.setup.before_install,
113
+ ext_layout=layout,
114
+ blocking=True,
115
+ )
116
+
117
+
118
+ def run_after_install_scripts(target_root: Path, extension: str, *, ext_layout: Path | None = None) -> None:
119
+ layout = _ext_layout_root(target_root, extension, ext_layout)
120
+ cfg = _extension_config_at(layout)
121
+ if not cfg.setup or not cfg.setup.after_install:
122
+ return
123
+ _run_script(
124
+ target_root,
125
+ extension,
126
+ cfg.setup.after_install,
127
+ ext_layout=layout,
128
+ blocking=False,
129
+ )
130
+
131
+
132
+ def run_before_uninstall_scripts(target_root: Path, extension: str, *, ext_layout: Path | None = None) -> None:
133
+ layout = _ext_layout_root(target_root, extension, ext_layout)
134
+ cfg = _extension_config_at(layout)
135
+ if not cfg.setup:
136
+ return
137
+ if not cfg.setup.before_uninstall:
138
+ return
139
+ _run_script(
140
+ target_root,
141
+ extension,
142
+ cfg.setup.before_uninstall,
143
+ ext_layout=layout,
144
+ blocking=True,
145
+ )
146
+
147
+
148
+ def run_after_uninstall_scripts(target_root: Path, extension: str, *, ext_layout: Path | None = None) -> None:
149
+ layout = _ext_layout_root(target_root, extension, ext_layout)
150
+ if not layout.is_dir():
151
+ return
152
+ cfg = _extension_config_at(layout)
153
+ if not cfg.setup or not cfg.setup.after_uninstall:
154
+ return
155
+ _run_script(
156
+ target_root,
157
+ extension,
158
+ cfg.setup.after_uninstall,
159
+ ext_layout=layout,
160
+ blocking=False,
161
+ )
162
+
163
+
164
+ @dataclass
165
+ class AfterUninstallSnapshot:
166
+ script_path: Path | None
167
+ ext_version: str
168
+
169
+
170
+ def snapshot_after_uninstall_script(ext_dir: Path, cfg: ExtensionConfig) -> AfterUninstallSnapshot:
171
+ if not cfg.setup or not cfg.setup.after_uninstall:
172
+ return AfterUninstallSnapshot(None, cfg.version)
173
+ src = ext_dir / "setup" / cfg.setup.after_uninstall
174
+ if not src.is_file():
175
+ return AfterUninstallSnapshot(None, cfg.version)
176
+ td = Path(tempfile.mkdtemp(prefix="spawn-after-uninstall-"))
177
+ dst = td / src.name
178
+ shutil.copy2(src, dst)
179
+ return AfterUninstallSnapshot(dst, cfg.version)
180
+
181
+
182
+ def run_after_uninstall_from_snapshot(
183
+ target_root: Path, extension: str, snap: AfterUninstallSnapshot
184
+ ) -> None:
185
+ if snap.script_path is None:
186
+ return
187
+ ghost_ext = _extend_dir(target_root, extension)
188
+ env = os.environ.copy()
189
+ env["SPAWN_TARGET_ROOT"] = str(target_root.resolve())
190
+ env["SPAWN_EXT_NAME"] = extension
191
+ env["SPAWN_EXT_PATH"] = str(ghost_ext.resolve())
192
+ env["SPAWN_EXT_VERSION"] = snap.ext_version
193
+ env["SPAWN_TARGET_VERSION"] = _core_version(target_root)
194
+ proc = subprocess.run(
195
+ [sys.executable, str(snap.script_path)],
196
+ cwd=str(target_root),
197
+ env=env,
198
+ capture_output=True,
199
+ text=True,
200
+ check=False,
201
+ )
202
+ if proc.returncode != 0:
203
+ msg = f"After-uninstall script exited with status {proc.returncode}"
204
+ if proc.stderr.strip():
205
+ msg = f"{msg}: {proc.stderr.strip()}"
206
+ warnings.warn(msg, SpawnWarning)
207
+
208
+
209
+ def run_healthcheck_scripts(target_root: Path, extension: str, *, ext_layout: Path | None = None) -> bool:
210
+ layout = _ext_layout_root(target_root, extension, ext_layout)
211
+ cfg = _extension_config_at(layout)
212
+ if not cfg.setup or not cfg.setup.healthcheck:
213
+ return True
214
+ proc = subprocess.run(
215
+ [
216
+ sys.executable,
217
+ str(_setup_script_path(layout, cfg.setup.healthcheck)),
218
+ ],
219
+ cwd=str(target_root),
220
+ env={
221
+ **os.environ,
222
+ "SPAWN_TARGET_ROOT": str(target_root.resolve()),
223
+ "SPAWN_EXT_NAME": extension,
224
+ "SPAWN_EXT_PATH": str(layout.resolve()),
225
+ "SPAWN_EXT_VERSION": cfg.version,
226
+ "SPAWN_TARGET_VERSION": _core_version(target_root),
227
+ },
228
+ capture_output=True,
229
+ text=True,
230
+ check=False,
231
+ )
232
+ return proc.returncode == 0
233
+
234
+
235
+ __all__ = [
236
+ "AfterUninstallSnapshot",
237
+ "SCRIPT_ENV_VARS",
238
+ "_run_script",
239
+ "run_after_install_scripts",
240
+ "run_after_uninstall_scripts",
241
+ "run_before_install_scripts",
242
+ "run_before_uninstall_scripts",
243
+ "run_after_uninstall_from_snapshot",
244
+ "run_healthcheck_scripts",
245
+ "snapshot_after_uninstall_script",
246
+ ]
spawn_cli/errors.py ADDED
@@ -0,0 +1,9 @@
1
+ from __future__ import annotations
2
+
3
+
4
+ class SpawnError(Exception):
5
+ """Raised on errors that stop the command before mutation."""
6
+
7
+
8
+ class SpawnWarning(UserWarning):
9
+ """Raised (or printed) for recoverable inconsistencies."""
@@ -0,0 +1,63 @@
1
+ from __future__ import annotations
2
+
3
+ from . import registry # noqa: F401
4
+ from . import _stub # noqa: F401 — load stubs before concrete adapters
5
+ from . import cursor # noqa: F401
6
+ from . import claude_code # noqa: F401
7
+ from . import codex # noqa: F401
8
+ from . import gemini_cli # noqa: F401
9
+ from . import github_copilot # noqa: F401
10
+ from . import windsurf # noqa: F401
11
+ from ._stub import StubAdapter
12
+ from .claude_code import ClaudeCodeAdapter
13
+ from .codex import CodexAdapter
14
+ from .cursor import CursorAdapter
15
+ from .gemini_cli import GeminiCliAdapter
16
+ from .github_copilot import GitHubCopilotAdapter
17
+ from .windsurf import WindsurfAdapter
18
+ from .registry import (
19
+ ALIASES,
20
+ IGNORE_BLOCK_END,
21
+ IGNORE_BLOCK_START,
22
+ SPAWN_BLOCK_END,
23
+ SPAWN_BLOCK_START,
24
+ DetectResult,
25
+ IdeAdapter,
26
+ IdeCapabilities,
27
+ detect_supported_ides,
28
+ get,
29
+ normalize_skill_name,
30
+ register,
31
+ remove_ignore_block,
32
+ render_skill_md,
33
+ rewrite_ignore_block,
34
+ rewrite_managed_block,
35
+ supported_ide_keys,
36
+ )
37
+
38
+ __all__ = [
39
+ "ALIASES",
40
+ "ClaudeCodeAdapter",
41
+ "CodexAdapter",
42
+ "CursorAdapter",
43
+ "GeminiCliAdapter",
44
+ "GitHubCopilotAdapter",
45
+ "DetectResult",
46
+ "IGNORE_BLOCK_END",
47
+ "IGNORE_BLOCK_START",
48
+ "IdeAdapter",
49
+ "IdeCapabilities",
50
+ "SPAWN_BLOCK_END",
51
+ "SPAWN_BLOCK_START",
52
+ "StubAdapter",
53
+ "WindsurfAdapter",
54
+ "detect_supported_ides",
55
+ "get",
56
+ "normalize_skill_name",
57
+ "register",
58
+ "remove_ignore_block",
59
+ "render_skill_md",
60
+ "rewrite_ignore_block",
61
+ "rewrite_managed_block",
62
+ "supported_ide_keys",
63
+ ]
@@ -0,0 +1,168 @@
1
+ """Shared IDE adapter rendering helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from pathlib import Path
7
+
8
+ from spawn_cli.core.low_level import _norm_read_path, normalize_skill_name
9
+ from spawn_cli.models.skill import SkillFileRef, SkillMetadata
10
+
11
+ SPAWN_BLOCK_START = "<!-- spawn:start -->"
12
+ SPAWN_BLOCK_END = "<!-- spawn:end -->"
13
+
14
+ IGNORE_BLOCK_START = "# spawn:start"
15
+ IGNORE_BLOCK_END = "# spawn:end"
16
+
17
+ _NAV_YAML_FILE = "spawn/navigation.yaml"
18
+ _NAV_YAML_DESCRIPTION = "Merged Spawn navigation (read-required, read-contextual)."
19
+
20
+
21
+ def _mandatory_reads_for_render(skill: SkillMetadata) -> list[SkillFileRef]:
22
+ """Build mandatory list with spawn/navigation.yaml deduped and last."""
23
+ nav_key = _norm_read_path(_NAV_YAML_FILE)
24
+ nav_ref: SkillFileRef | None = None
25
+ others: list[SkillFileRef] = []
26
+ for ref in skill.required_read:
27
+ if _norm_read_path(ref.file) == nav_key:
28
+ if nav_ref is None:
29
+ nav_ref = ref
30
+ else:
31
+ others.append(ref)
32
+ if nav_ref is None:
33
+ nav_ref = SkillFileRef(file=_NAV_YAML_FILE, description=_NAV_YAML_DESCRIPTION)
34
+ return others + [nav_ref]
35
+
36
+
37
+ def render_skill_md(skill: SkillMetadata) -> str:
38
+ """Render normalized skill to Markdown with frontmatter, skill body,
39
+ mandatory reads (navigation path last), and contextual reads."""
40
+ lines = [
41
+ "---",
42
+ f"name: {skill.name}",
43
+ f"description: {skill.description}",
44
+ "---",
45
+ "",
46
+ skill.content,
47
+ "",
48
+ "Mandatory reads:",
49
+ ]
50
+ for ref in _mandatory_reads_for_render(skill):
51
+ lines += [f"- `{ref.file}` - {ref.description}"]
52
+ lines += [""]
53
+ if skill.auto_read:
54
+ lines += ["Contextual reads:"]
55
+ for ref in skill.auto_read:
56
+ lines += [f"- `{ref.file}` - {ref.description}"]
57
+ lines += [""]
58
+ return "\n".join(lines)
59
+
60
+
61
+ def rewrite_managed_block(file_path: Path, prompt: str) -> None:
62
+ """Read file (or empty string), replace/insert spawn block, write back."""
63
+ content = file_path.read_text(encoding="utf-8") if file_path.exists() else ""
64
+ block = f"{SPAWN_BLOCK_START}\n{prompt}\n{SPAWN_BLOCK_END}"
65
+ if SPAWN_BLOCK_START in content:
66
+ content = re.sub(
67
+ rf"{re.escape(SPAWN_BLOCK_START)}.*?{re.escape(SPAWN_BLOCK_END)}",
68
+ block,
69
+ content,
70
+ flags=re.DOTALL,
71
+ )
72
+ else:
73
+ content = block + "\n" + content
74
+ file_path.parent.mkdir(parents=True, exist_ok=True)
75
+ file_path.write_text(content, encoding="utf-8")
76
+
77
+
78
+ def _partition_ignore_block(lines: list[str]) -> tuple[list[str], list[str], list[str]]:
79
+ start_idx: int | None = None
80
+ end_idx: int | None = None
81
+ for i, ln in enumerate(lines):
82
+ if ln.strip() == IGNORE_BLOCK_START:
83
+ start_idx = i
84
+ break
85
+ if start_idx is None:
86
+ return lines[:], [], []
87
+ for j in range(start_idx + 1, len(lines)):
88
+ if lines[j].strip() == IGNORE_BLOCK_END:
89
+ end_idx = j
90
+ break
91
+ if end_idx is None:
92
+ return lines[:start_idx], lines[start_idx + 1 :], []
93
+ return lines[:start_idx], lines[start_idx + 1 : end_idx], lines[end_idx + 1 :]
94
+
95
+
96
+ def rewrite_ignore_block(file_path: Path, globs: list[str]) -> None:
97
+ """Merge Spawn-owned globs into a text ignore file using # spawn:start / # spawn:end."""
98
+ raw_lines = (
99
+ file_path.read_text(encoding="utf-8").splitlines(True)
100
+ if file_path.exists()
101
+ else []
102
+ )
103
+ before, _inner, after = _partition_ignore_block(raw_lines)
104
+ body_lines = [g.strip() + "\n" for g in globs if g.strip()]
105
+ block_lines = (
106
+ [IGNORE_BLOCK_START + "\n"]
107
+ + body_lines
108
+ + [IGNORE_BLOCK_END + "\n"]
109
+ )
110
+ new_lines = before + block_lines + after
111
+ file_path.parent.mkdir(parents=True, exist_ok=True)
112
+ file_path.write_text("".join(new_lines), encoding="utf-8")
113
+
114
+
115
+ def remove_ignore_block(file_path: Path, globs: list[str]) -> None:
116
+ """Remove Spawn-managed ignore lines or the whole managed block.
117
+
118
+ If ``globs`` is empty, removes the entire ``# spawn:start`` … ``# spawn:end`` block.
119
+ If ``globs`` is non-empty, removes only inner lines whose stripped text matches
120
+ an entry in ``globs``; preserves other inner lines and all content outside the block.
121
+ If no inner lines remain, the block markers are removed as well.
122
+ """
123
+ if not file_path.exists():
124
+ return
125
+ raw_lines = file_path.read_text(encoding="utf-8").splitlines(True)
126
+ start_idx: int | None = None
127
+ end_idx: int | None = None
128
+ for i, ln in enumerate(raw_lines):
129
+ if ln.strip() == IGNORE_BLOCK_START:
130
+ start_idx = i
131
+ break
132
+ if start_idx is None:
133
+ return
134
+ for j in range(start_idx + 1, len(raw_lines)):
135
+ if raw_lines[j].strip() == IGNORE_BLOCK_END:
136
+ end_idx = j
137
+ break
138
+ if end_idx is None:
139
+ return
140
+
141
+ before = raw_lines[:start_idx]
142
+ inner = raw_lines[start_idx + 1 : end_idx]
143
+ after = raw_lines[end_idx + 1 :]
144
+
145
+ if not globs:
146
+ file_path.write_text("".join(before + after), encoding="utf-8")
147
+ return
148
+
149
+ drop = {g.strip() for g in globs if g.strip()}
150
+ kept = [ln for ln in inner if ln.strip() not in drop]
151
+ if not kept:
152
+ file_path.write_text("".join(before + after), encoding="utf-8")
153
+ else:
154
+ block_lines = [IGNORE_BLOCK_START + "\n"] + kept + [IGNORE_BLOCK_END + "\n"]
155
+ file_path.write_text("".join(before + block_lines + after), encoding="utf-8")
156
+
157
+
158
+ __all__ = [
159
+ "IGNORE_BLOCK_END",
160
+ "IGNORE_BLOCK_START",
161
+ "SPAWN_BLOCK_END",
162
+ "SPAWN_BLOCK_START",
163
+ "normalize_skill_name",
164
+ "remove_ignore_block",
165
+ "render_skill_md",
166
+ "rewrite_ignore_block",
167
+ "rewrite_managed_block",
168
+ ]
spawn_cli/ide/_stub.py ADDED
@@ -0,0 +1,63 @@
1
+ from __future__ import annotations
2
+
3
+ import warnings
4
+ from pathlib import Path
5
+
6
+ from .registry import DetectResult, IdeAdapter, IdeCapabilities, register
7
+ from spawn_cli.models.mcp import NormalizedMcp
8
+ from spawn_cli.models.skill import SkillMetadata
9
+
10
+
11
+ class StubAdapter(IdeAdapter):
12
+ def __init__(self, key: str, caps: IdeCapabilities) -> None:
13
+ self.key = key
14
+ self._caps = caps
15
+
16
+ def detect(self, target_root: Path) -> DetectResult:
17
+ del target_root
18
+ return DetectResult(used_in_repo=False, capabilities=self._caps)
19
+
20
+ def add_skills(self, target_root: Path, skill_metadata: list[SkillMetadata]) -> list[dict]:
21
+ del target_root, skill_metadata
22
+ warnings.warn(f"{self.key}: skill rendering not yet implemented")
23
+ return []
24
+
25
+ def remove_skills(self, target_root: Path, rendered_paths: list[dict]) -> None:
26
+ del target_root, rendered_paths
27
+ warnings.warn(f"{self.key}: skill removal not yet implemented")
28
+
29
+ def add_mcp(self, target_root: Path, normalized_mcp: NormalizedMcp) -> list[str]:
30
+ del target_root, normalized_mcp
31
+ warnings.warn(f"{self.key}: MCP rendering not yet implemented")
32
+ return []
33
+
34
+ def remove_mcp(self, target_root: Path, rendered_mcp_names: list[str]) -> None:
35
+ del target_root, rendered_mcp_names
36
+ warnings.warn(f"{self.key}: MCP removal not yet implemented")
37
+
38
+ def add_agent_ignore(self, target_root: Path, globs: list[str]) -> None:
39
+ del target_root, globs
40
+ warnings.warn(f"{self.key}: agent ignore not yet implemented")
41
+
42
+ def remove_agent_ignore(self, target_root: Path, globs: list[str]) -> None:
43
+ del target_root, globs
44
+ warnings.warn(f"{self.key}: agent ignore removal not yet implemented")
45
+
46
+ def rewrite_entry_point(self, target_root: Path, prompt: str) -> str:
47
+ del target_root, prompt
48
+ warnings.warn(f"{self.key}: entry point not yet implemented")
49
+ return ""
50
+
51
+
52
+ _STUBS: list[tuple[str, IdeCapabilities]] = [
53
+ ("qoder", IdeCapabilities("native", "project", "project", "agents-md")),
54
+ ("qwen-code", IdeCapabilities("native", "project", "project", "qwen-md")),
55
+ ("aider", IdeCapabilities("entry-only", "unsupported", "project", "conventions-md")),
56
+ ("zed", IdeCapabilities("unsupported", "project", "unsupported", "agents-md")),
57
+ ("devin", IdeCapabilities("native", "project", "unsupported", "agents-md")),
58
+ ]
59
+
60
+ for _key, _caps in _STUBS:
61
+ register(StubAdapter(_key, _caps))
62
+
63
+ __all__ = ["StubAdapter"]