spawn-cli 0.1.19__tar.gz → 0.1.21__tar.gz
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-0.1.19/src/spawn_cli.egg-info → spawn_cli-0.1.21}/PKG-INFO +1 -1
- {spawn_cli-0.1.19 → spawn_cli-0.1.21}/pyproject.toml +1 -1
- {spawn_cli-0.1.19 → spawn_cli-0.1.21}/src/spawn_cli/core/low_level.py +1 -0
- {spawn_cli-0.1.19 → spawn_cli-0.1.21}/src/spawn_cli/ide/__init__.py +3 -0
- {spawn_cli-0.1.19 → spawn_cli-0.1.21}/src/spawn_cli/ide/_vacancy.py +36 -0
- {spawn_cli-0.1.19 → spawn_cli-0.1.21}/src/spawn_cli/ide/cline.py +5 -3
- spawn_cli-0.1.21/src/spawn_cli/ide/opencode.py +187 -0
- {spawn_cli-0.1.19 → spawn_cli-0.1.21/src/spawn_cli.egg-info}/PKG-INFO +1 -1
- {spawn_cli-0.1.19 → spawn_cli-0.1.21}/src/spawn_cli.egg-info/SOURCES.txt +1 -0
- {spawn_cli-0.1.19 → spawn_cli-0.1.21}/LICENSE +0 -0
- {spawn_cli-0.1.19 → spawn_cli-0.1.21}/README.md +0 -0
- {spawn_cli-0.1.19 → spawn_cli-0.1.21}/setup.cfg +0 -0
- {spawn_cli-0.1.19 → spawn_cli-0.1.21}/src/spawn_cli/__init__.py +0 -0
- {spawn_cli-0.1.19 → spawn_cli-0.1.21}/src/spawn_cli/cli.py +0 -0
- {spawn_cli-0.1.19 → spawn_cli-0.1.21}/src/spawn_cli/core/__init__.py +0 -0
- {spawn_cli-0.1.19 → spawn_cli-0.1.21}/src/spawn_cli/core/download.py +0 -0
- {spawn_cli-0.1.19 → spawn_cli-0.1.21}/src/spawn_cli/core/errors.py +0 -0
- {spawn_cli-0.1.19 → spawn_cli-0.1.21}/src/spawn_cli/core/high_level.py +0 -0
- {spawn_cli-0.1.19 → spawn_cli-0.1.21}/src/spawn_cli/core/mcp_stdio.py +0 -0
- {spawn_cli-0.1.19 → spawn_cli-0.1.21}/src/spawn_cli/core/scripts.py +0 -0
- {spawn_cli-0.1.19 → spawn_cli-0.1.21}/src/spawn_cli/errors.py +0 -0
- {spawn_cli-0.1.19 → spawn_cli-0.1.21}/src/spawn_cli/ide/_helpers.py +0 -0
- {spawn_cli-0.1.19 → spawn_cli-0.1.21}/src/spawn_cli/ide/_stub.py +0 -0
- {spawn_cli-0.1.19 → spawn_cli-0.1.21}/src/spawn_cli/ide/claude_code.py +0 -0
- {spawn_cli-0.1.19 → spawn_cli-0.1.21}/src/spawn_cli/ide/codex.py +0 -0
- {spawn_cli-0.1.19 → spawn_cli-0.1.21}/src/spawn_cli/ide/cursor.py +0 -0
- {spawn_cli-0.1.19 → spawn_cli-0.1.21}/src/spawn_cli/ide/gemini_cli.py +0 -0
- {spawn_cli-0.1.19 → spawn_cli-0.1.21}/src/spawn_cli/ide/github_copilot.py +0 -0
- {spawn_cli-0.1.19 → spawn_cli-0.1.21}/src/spawn_cli/ide/mcp_stdio_argv.py +0 -0
- {spawn_cli-0.1.19 → spawn_cli-0.1.21}/src/spawn_cli/ide/registry.py +0 -0
- {spawn_cli-0.1.19 → spawn_cli-0.1.21}/src/spawn_cli/ide/windsurf.py +0 -0
- {spawn_cli-0.1.19 → spawn_cli-0.1.21}/src/spawn_cli/io/__init__.py +0 -0
- {spawn_cli-0.1.19 → spawn_cli-0.1.21}/src/spawn_cli/io/json_io.py +0 -0
- {spawn_cli-0.1.19 → spawn_cli-0.1.21}/src/spawn_cli/io/lock.py +0 -0
- {spawn_cli-0.1.19 → spawn_cli-0.1.21}/src/spawn_cli/io/paths.py +0 -0
- {spawn_cli-0.1.19 → spawn_cli-0.1.21}/src/spawn_cli/io/text_io.py +0 -0
- {spawn_cli-0.1.19 → spawn_cli-0.1.21}/src/spawn_cli/io/toml_io.py +0 -0
- {spawn_cli-0.1.19 → spawn_cli-0.1.21}/src/spawn_cli/io/yaml_io.py +0 -0
- {spawn_cli-0.1.19 → spawn_cli-0.1.21}/src/spawn_cli/models/__init__.py +0 -0
- {spawn_cli-0.1.19 → spawn_cli-0.1.21}/src/spawn_cli/models/config.py +0 -0
- {spawn_cli-0.1.19 → spawn_cli-0.1.21}/src/spawn_cli/models/mcp.py +0 -0
- {spawn_cli-0.1.19 → spawn_cli-0.1.21}/src/spawn_cli/models/metadata.py +0 -0
- {spawn_cli-0.1.19 → spawn_cli-0.1.21}/src/spawn_cli/models/navigation.py +0 -0
- {spawn_cli-0.1.19 → spawn_cli-0.1.21}/src/spawn_cli/models/skill.py +0 -0
- {spawn_cli-0.1.19 → spawn_cli-0.1.21}/src/spawn_cli/resources/__init__.py +0 -0
- {spawn_cli-0.1.19 → spawn_cli-0.1.21}/src/spawn_cli/resources/default_core_config.yaml +0 -0
- {spawn_cli-0.1.19 → spawn_cli-0.1.21}/src/spawn_cli/warnings_display.py +0 -0
- {spawn_cli-0.1.19 → spawn_cli-0.1.21}/src/spawn_cli.egg-info/dependency_links.txt +0 -0
- {spawn_cli-0.1.19 → spawn_cli-0.1.21}/src/spawn_cli.egg-info/entry_points.txt +0 -0
- {spawn_cli-0.1.19 → spawn_cli-0.1.21}/src/spawn_cli.egg-info/requires.txt +0 -0
- {spawn_cli-0.1.19 → spawn_cli-0.1.21}/src/spawn_cli.egg-info/top_level.txt +0 -0
- {spawn_cli-0.1.19 → spawn_cli-0.1.21}/tests/test_cli.py +0 -0
- {spawn_cli-0.1.19 → spawn_cli-0.1.21}/tests/test_warnings_display.py +0 -0
|
@@ -9,8 +9,10 @@ from . import gemini_cli # noqa: F401
|
|
|
9
9
|
from . import github_copilot # noqa: F401
|
|
10
10
|
from . import windsurf # noqa: F401
|
|
11
11
|
from . import cline # noqa: F401
|
|
12
|
+
from . import opencode # noqa: F401
|
|
12
13
|
from ._stub import StubAdapter
|
|
13
14
|
from .cline import ClineAdapter
|
|
15
|
+
from .opencode import OpenCodeAdapter
|
|
14
16
|
from .claude_code import ClaudeCodeAdapter
|
|
15
17
|
from .codex import CodexAdapter
|
|
16
18
|
from .cursor import CursorAdapter
|
|
@@ -45,6 +47,7 @@ from .registry import (
|
|
|
45
47
|
__all__ = [
|
|
46
48
|
"ALIASES",
|
|
47
49
|
"ClineAdapter",
|
|
50
|
+
"OpenCodeAdapter",
|
|
48
51
|
"ClaudeCodeAdapter",
|
|
49
52
|
"CodexAdapter",
|
|
50
53
|
"CursorAdapter",
|
|
@@ -226,6 +226,41 @@ def finalize_github_copilot_repo(target_root: Path) -> None:
|
|
|
226
226
|
pass
|
|
227
227
|
|
|
228
228
|
|
|
229
|
+
_OPENCODE_CONFIG = "opencode.json"
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _opencode_config_vacant(path: Path) -> bool:
|
|
233
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
234
|
+
if not isinstance(data, dict):
|
|
235
|
+
return False # user config in an unexpected shape: keep untouched
|
|
236
|
+
if data == {}:
|
|
237
|
+
return True
|
|
238
|
+
for key, value in data.items():
|
|
239
|
+
if key in ("mcp", "permission"):
|
|
240
|
+
if value: # non-empty -> user content or live servers
|
|
241
|
+
return False
|
|
242
|
+
elif json_value_nonempty(value): # unknown user key with substance
|
|
243
|
+
return False
|
|
244
|
+
return True
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def finalize_opencode_repo(target_root: Path) -> None:
|
|
248
|
+
"""OpenCode adapter: root `opencode.json` (keys mcp, permission) and `.opencode/skills`; drop when vacant."""
|
|
249
|
+
config = target_root / _OPENCODE_CONFIG
|
|
250
|
+
if config.is_file():
|
|
251
|
+
try:
|
|
252
|
+
vacant = _opencode_config_vacant(config)
|
|
253
|
+
except json.JSONDecodeError:
|
|
254
|
+
vacant = False # keep unparseable user config untouched
|
|
255
|
+
if vacant:
|
|
256
|
+
config.unlink()
|
|
257
|
+
skills_dir = target_root / ".opencode" / "skills"
|
|
258
|
+
if skills_dir.is_dir() and not dir_has_any_file(skills_dir):
|
|
259
|
+
prune_empty_directories_under(target_root / ".opencode")
|
|
260
|
+
if (target_root / ".opencode").is_dir() and not dir_has_any_file(target_root / ".opencode"):
|
|
261
|
+
(target_root / ".opencode").rmdir()
|
|
262
|
+
|
|
263
|
+
|
|
229
264
|
def finalize_cline_repo(target_root: Path) -> None:
|
|
230
265
|
"""Cline adapter: `.cline/skills` dotdir and root `.clineignore`; drop both when vacant."""
|
|
231
266
|
finalize_standard_dotdir_skills_and_mcp(
|
|
@@ -244,6 +279,7 @@ __all__ = [
|
|
|
244
279
|
"dir_has_any_file",
|
|
245
280
|
"finalize_claude_repo",
|
|
246
281
|
"finalize_cline_repo",
|
|
282
|
+
"finalize_opencode_repo",
|
|
247
283
|
"finalize_codex_repo",
|
|
248
284
|
"finalize_github_copilot_repo",
|
|
249
285
|
"finalize_standard_dotdir_skills_and_mcp",
|
|
@@ -5,6 +5,7 @@ from __future__ import annotations
|
|
|
5
5
|
import warnings
|
|
6
6
|
from pathlib import Path
|
|
7
7
|
|
|
8
|
+
from spawn_cli.errors import SpawnWarning
|
|
8
9
|
from spawn_cli.ide import _vacancy as _vac
|
|
9
10
|
from spawn_cli.ide._helpers import (
|
|
10
11
|
clear_split_agent_ignore_file,
|
|
@@ -73,14 +74,15 @@ class ClineAdapter(IdeAdapter):
|
|
|
73
74
|
del normalized_mcp
|
|
74
75
|
del target_root
|
|
75
76
|
warnings.warn(
|
|
76
|
-
"
|
|
77
|
-
"MCP
|
|
77
|
+
"Cline has no project-scope MCP settings; "
|
|
78
|
+
"MCP servers stay configured globally in the Cline app",
|
|
79
|
+
SpawnWarning,
|
|
78
80
|
)
|
|
79
81
|
return []
|
|
80
82
|
|
|
81
83
|
def remove_mcp(self, target_root: Path, rendered_mcp_names: list[str]) -> None:
|
|
82
84
|
del target_root, rendered_mcp_names
|
|
83
|
-
warnings.warn("
|
|
85
|
+
warnings.warn("MCP is not managed by spawn for cline; nothing to remove", SpawnWarning)
|
|
84
86
|
|
|
85
87
|
def add_agent_ignore(self, target_root: Path, globs: list[str]) -> None:
|
|
86
88
|
rewrite_ignore_block(target_root / ".clineignore", globs)
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"""OpenCode CLI adapter: .opencode/skills, root opencode.json (mcp + permission), AGENTS.md."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
import warnings
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from spawn_cli.core import low_level as ll
|
|
11
|
+
from spawn_cli.errors import SpawnError, SpawnWarning
|
|
12
|
+
from spawn_cli.ide import _vacancy as _vac
|
|
13
|
+
from spawn_cli.ide.mcp_stdio_argv import mcp_stdio_argv
|
|
14
|
+
from spawn_cli.ide.registry import (
|
|
15
|
+
DetectResult,
|
|
16
|
+
IdeAdapter,
|
|
17
|
+
IdeCapabilities,
|
|
18
|
+
normalize_skill_name,
|
|
19
|
+
register,
|
|
20
|
+
render_skill_md,
|
|
21
|
+
rewrite_managed_block,
|
|
22
|
+
)
|
|
23
|
+
from spawn_cli.models.mcp import McpServer, NormalizedMcp
|
|
24
|
+
from spawn_cli.models.skill import SkillMetadata
|
|
25
|
+
|
|
26
|
+
_CONFIG = "opencode.json"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _load_config(target_root: Path) -> dict:
|
|
30
|
+
path = target_root / _CONFIG
|
|
31
|
+
if not path.exists():
|
|
32
|
+
if (target_root / "opencode.jsonc").exists():
|
|
33
|
+
warnings.warn(
|
|
34
|
+
"OpenCode: Spawn manages opencode.json; existing opencode.jsonc is not merged",
|
|
35
|
+
SpawnWarning,
|
|
36
|
+
)
|
|
37
|
+
return {}
|
|
38
|
+
try:
|
|
39
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
40
|
+
except json.JSONDecodeError as exc:
|
|
41
|
+
raise SpawnError(f"OpenCode config is not valid JSON: {path} ({exc})") from exc
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _save_config(target_root: Path, data: dict) -> None:
|
|
45
|
+
(target_root / _CONFIG).write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _sanitize_skill_name(name: str) -> str:
|
|
49
|
+
name = normalize_skill_name(name)
|
|
50
|
+
name = name.replace("_", "-").replace(".", "-")
|
|
51
|
+
name = re.sub(r"-{2,}", "-", name).strip("-")
|
|
52
|
+
return name
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _build_mcp_entry(server: McpServer) -> dict:
|
|
56
|
+
transport = server.transport
|
|
57
|
+
if transport.type == "stdio":
|
|
58
|
+
if server.spawn_stdio_proxy:
|
|
59
|
+
entry = {
|
|
60
|
+
"type": "local",
|
|
61
|
+
"command": ["spawn"] + mcp_stdio_argv(server.extension, server.name),
|
|
62
|
+
}
|
|
63
|
+
else:
|
|
64
|
+
entry = {"type": "local", "command": [transport.command, *list(transport.args)]}
|
|
65
|
+
else: # streamable-http and sse both render as remote
|
|
66
|
+
entry = {"type": "remote", "url": transport.url}
|
|
67
|
+
if transport.headers:
|
|
68
|
+
entry["headers"] = {k: f"{{env:{v}}}" for k, v in transport.headers.items()}
|
|
69
|
+
entry["enabled"] = True
|
|
70
|
+
if server.env:
|
|
71
|
+
entry["environment"] = {
|
|
72
|
+
k: f"{{env:{k}}}" if v.secret else (v.value or f"{{env:{k}}}")
|
|
73
|
+
for k, v in server.env.items()
|
|
74
|
+
}
|
|
75
|
+
return entry
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class OpenCodeAdapter(IdeAdapter):
|
|
79
|
+
key = "opencode"
|
|
80
|
+
|
|
81
|
+
def detect(self, target_root: Path) -> DetectResult:
|
|
82
|
+
used = (
|
|
83
|
+
(target_root / ".opencode").exists()
|
|
84
|
+
or (target_root / "opencode.json").exists()
|
|
85
|
+
or (target_root / "opencode.jsonc").exists()
|
|
86
|
+
)
|
|
87
|
+
return DetectResult(
|
|
88
|
+
used_in_repo=used,
|
|
89
|
+
capabilities=IdeCapabilities(
|
|
90
|
+
skills="native",
|
|
91
|
+
mcp="project",
|
|
92
|
+
agent_ignore="project",
|
|
93
|
+
entry_point="agents-md",
|
|
94
|
+
),
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
def add_skills(self, target_root: Path, skill_metadata: list[SkillMetadata]) -> list[dict]:
|
|
98
|
+
results = []
|
|
99
|
+
for skill in skill_metadata:
|
|
100
|
+
name = _sanitize_skill_name(skill.name)
|
|
101
|
+
skill_dir = target_root / ".opencode" / "skills" / name
|
|
102
|
+
skill_dir.mkdir(parents=True, exist_ok=True)
|
|
103
|
+
skill_path = skill_dir / "SKILL.md"
|
|
104
|
+
if skill_path.exists():
|
|
105
|
+
warnings.warn(f"Overwriting existing rendered skill: {skill_path}", SpawnWarning)
|
|
106
|
+
# OpenCode requires frontmatter name == directory name.
|
|
107
|
+
rendered = render_skill_md(skill.model_copy(update={"name": name}))
|
|
108
|
+
skill_path.write_text(rendered, encoding="utf-8")
|
|
109
|
+
results.append({"skill": skill.name, "path": skill_path.relative_to(target_root).as_posix()})
|
|
110
|
+
return results
|
|
111
|
+
|
|
112
|
+
def remove_skills(self, target_root: Path, rendered_paths: list[dict]) -> None:
|
|
113
|
+
for entry in rendered_paths:
|
|
114
|
+
p = target_root / entry["path"]
|
|
115
|
+
if p.exists():
|
|
116
|
+
p.unlink()
|
|
117
|
+
if p.parent.exists() and not any(p.parent.iterdir()):
|
|
118
|
+
p.parent.rmdir()
|
|
119
|
+
|
|
120
|
+
def add_mcp(self, target_root: Path, normalized_mcp: NormalizedMcp) -> list[str]:
|
|
121
|
+
data = _load_config(target_root)
|
|
122
|
+
mcp = data.setdefault("mcp", {})
|
|
123
|
+
for server in normalized_mcp.servers:
|
|
124
|
+
mcp[server.name] = _build_mcp_entry(server)
|
|
125
|
+
_save_config(target_root, data)
|
|
126
|
+
return [s.name for s in normalized_mcp.servers]
|
|
127
|
+
|
|
128
|
+
def remove_mcp(self, target_root: Path, rendered_mcp_names: list[str]) -> None:
|
|
129
|
+
if not (target_root / _CONFIG).exists():
|
|
130
|
+
return
|
|
131
|
+
data = _load_config(target_root)
|
|
132
|
+
mcp = data.get("mcp")
|
|
133
|
+
if isinstance(mcp, dict):
|
|
134
|
+
for name in rendered_mcp_names:
|
|
135
|
+
mcp.pop(name, None)
|
|
136
|
+
if not mcp:
|
|
137
|
+
data.pop("mcp", None)
|
|
138
|
+
_save_config(target_root, data)
|
|
139
|
+
|
|
140
|
+
def add_agent_ignore(self, target_root: Path, globs: list[str]) -> None:
|
|
141
|
+
data = _load_config(target_root)
|
|
142
|
+
perms = data.setdefault("permission", {})
|
|
143
|
+
for section in ("read", "edit"):
|
|
144
|
+
rules = perms.setdefault(section, {})
|
|
145
|
+
if not isinstance(rules, dict):
|
|
146
|
+
rules = {}
|
|
147
|
+
perms[section] = rules
|
|
148
|
+
for glob in globs:
|
|
149
|
+
rules[glob] = "deny"
|
|
150
|
+
_save_config(target_root, data)
|
|
151
|
+
|
|
152
|
+
def remove_agent_ignore(self, target_root: Path, globs: list[str]) -> None:
|
|
153
|
+
if not (target_root / _CONFIG).exists():
|
|
154
|
+
return
|
|
155
|
+
data = _load_config(target_root)
|
|
156
|
+
perms = data.get("permission")
|
|
157
|
+
if not isinstance(perms, dict):
|
|
158
|
+
return
|
|
159
|
+
drop = set(globs)
|
|
160
|
+
for section in ("read", "edit"):
|
|
161
|
+
rules = perms.get(section)
|
|
162
|
+
if isinstance(rules, dict):
|
|
163
|
+
for glob in drop:
|
|
164
|
+
rules.pop(glob, None)
|
|
165
|
+
if not rules:
|
|
166
|
+
perms.pop(section, None)
|
|
167
|
+
if not perms:
|
|
168
|
+
data.pop("permission", None)
|
|
169
|
+
_save_config(target_root, data)
|
|
170
|
+
|
|
171
|
+
def clear_spawn_agent_ignore(self, target_root: Path) -> None:
|
|
172
|
+
recorded = ll.get_agent_ignore_list(target_root, self.key)
|
|
173
|
+
if recorded:
|
|
174
|
+
self.remove_agent_ignore(target_root, recorded)
|
|
175
|
+
|
|
176
|
+
def rewrite_entry_point(self, target_root: Path, prompt: str) -> str:
|
|
177
|
+
ep = target_root / "AGENTS.md"
|
|
178
|
+
rewrite_managed_block(ep, prompt)
|
|
179
|
+
return ep.relative_to(target_root).as_posix()
|
|
180
|
+
|
|
181
|
+
def finalize_repo_after_ide_removed(self, target_root: Path) -> None:
|
|
182
|
+
_vac.finalize_opencode_repo(target_root)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
register(OpenCodeAdapter())
|
|
186
|
+
|
|
187
|
+
__all__ = ["OpenCodeAdapter"]
|
|
@@ -29,6 +29,7 @@ src/spawn_cli/ide/cursor.py
|
|
|
29
29
|
src/spawn_cli/ide/gemini_cli.py
|
|
30
30
|
src/spawn_cli/ide/github_copilot.py
|
|
31
31
|
src/spawn_cli/ide/mcp_stdio_argv.py
|
|
32
|
+
src/spawn_cli/ide/opencode.py
|
|
32
33
|
src/spawn_cli/ide/registry.py
|
|
33
34
|
src/spawn_cli/ide/windsurf.py
|
|
34
35
|
src/spawn_cli/io/__init__.py
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|