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,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,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"]
|