spawn-cli 0.1.20__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.
Files changed (53) hide show
  1. {spawn_cli-0.1.20/src/spawn_cli.egg-info → spawn_cli-0.1.21}/PKG-INFO +1 -1
  2. {spawn_cli-0.1.20 → spawn_cli-0.1.21}/pyproject.toml +1 -1
  3. {spawn_cli-0.1.20 → spawn_cli-0.1.21}/src/spawn_cli/core/low_level.py +1 -0
  4. {spawn_cli-0.1.20 → spawn_cli-0.1.21}/src/spawn_cli/ide/__init__.py +3 -0
  5. {spawn_cli-0.1.20 → spawn_cli-0.1.21}/src/spawn_cli/ide/_vacancy.py +36 -0
  6. spawn_cli-0.1.21/src/spawn_cli/ide/opencode.py +187 -0
  7. {spawn_cli-0.1.20 → spawn_cli-0.1.21/src/spawn_cli.egg-info}/PKG-INFO +1 -1
  8. {spawn_cli-0.1.20 → spawn_cli-0.1.21}/src/spawn_cli.egg-info/SOURCES.txt +1 -0
  9. {spawn_cli-0.1.20 → spawn_cli-0.1.21}/LICENSE +0 -0
  10. {spawn_cli-0.1.20 → spawn_cli-0.1.21}/README.md +0 -0
  11. {spawn_cli-0.1.20 → spawn_cli-0.1.21}/setup.cfg +0 -0
  12. {spawn_cli-0.1.20 → spawn_cli-0.1.21}/src/spawn_cli/__init__.py +0 -0
  13. {spawn_cli-0.1.20 → spawn_cli-0.1.21}/src/spawn_cli/cli.py +0 -0
  14. {spawn_cli-0.1.20 → spawn_cli-0.1.21}/src/spawn_cli/core/__init__.py +0 -0
  15. {spawn_cli-0.1.20 → spawn_cli-0.1.21}/src/spawn_cli/core/download.py +0 -0
  16. {spawn_cli-0.1.20 → spawn_cli-0.1.21}/src/spawn_cli/core/errors.py +0 -0
  17. {spawn_cli-0.1.20 → spawn_cli-0.1.21}/src/spawn_cli/core/high_level.py +0 -0
  18. {spawn_cli-0.1.20 → spawn_cli-0.1.21}/src/spawn_cli/core/mcp_stdio.py +0 -0
  19. {spawn_cli-0.1.20 → spawn_cli-0.1.21}/src/spawn_cli/core/scripts.py +0 -0
  20. {spawn_cli-0.1.20 → spawn_cli-0.1.21}/src/spawn_cli/errors.py +0 -0
  21. {spawn_cli-0.1.20 → spawn_cli-0.1.21}/src/spawn_cli/ide/_helpers.py +0 -0
  22. {spawn_cli-0.1.20 → spawn_cli-0.1.21}/src/spawn_cli/ide/_stub.py +0 -0
  23. {spawn_cli-0.1.20 → spawn_cli-0.1.21}/src/spawn_cli/ide/claude_code.py +0 -0
  24. {spawn_cli-0.1.20 → spawn_cli-0.1.21}/src/spawn_cli/ide/cline.py +0 -0
  25. {spawn_cli-0.1.20 → spawn_cli-0.1.21}/src/spawn_cli/ide/codex.py +0 -0
  26. {spawn_cli-0.1.20 → spawn_cli-0.1.21}/src/spawn_cli/ide/cursor.py +0 -0
  27. {spawn_cli-0.1.20 → spawn_cli-0.1.21}/src/spawn_cli/ide/gemini_cli.py +0 -0
  28. {spawn_cli-0.1.20 → spawn_cli-0.1.21}/src/spawn_cli/ide/github_copilot.py +0 -0
  29. {spawn_cli-0.1.20 → spawn_cli-0.1.21}/src/spawn_cli/ide/mcp_stdio_argv.py +0 -0
  30. {spawn_cli-0.1.20 → spawn_cli-0.1.21}/src/spawn_cli/ide/registry.py +0 -0
  31. {spawn_cli-0.1.20 → spawn_cli-0.1.21}/src/spawn_cli/ide/windsurf.py +0 -0
  32. {spawn_cli-0.1.20 → spawn_cli-0.1.21}/src/spawn_cli/io/__init__.py +0 -0
  33. {spawn_cli-0.1.20 → spawn_cli-0.1.21}/src/spawn_cli/io/json_io.py +0 -0
  34. {spawn_cli-0.1.20 → spawn_cli-0.1.21}/src/spawn_cli/io/lock.py +0 -0
  35. {spawn_cli-0.1.20 → spawn_cli-0.1.21}/src/spawn_cli/io/paths.py +0 -0
  36. {spawn_cli-0.1.20 → spawn_cli-0.1.21}/src/spawn_cli/io/text_io.py +0 -0
  37. {spawn_cli-0.1.20 → spawn_cli-0.1.21}/src/spawn_cli/io/toml_io.py +0 -0
  38. {spawn_cli-0.1.20 → spawn_cli-0.1.21}/src/spawn_cli/io/yaml_io.py +0 -0
  39. {spawn_cli-0.1.20 → spawn_cli-0.1.21}/src/spawn_cli/models/__init__.py +0 -0
  40. {spawn_cli-0.1.20 → spawn_cli-0.1.21}/src/spawn_cli/models/config.py +0 -0
  41. {spawn_cli-0.1.20 → spawn_cli-0.1.21}/src/spawn_cli/models/mcp.py +0 -0
  42. {spawn_cli-0.1.20 → spawn_cli-0.1.21}/src/spawn_cli/models/metadata.py +0 -0
  43. {spawn_cli-0.1.20 → spawn_cli-0.1.21}/src/spawn_cli/models/navigation.py +0 -0
  44. {spawn_cli-0.1.20 → spawn_cli-0.1.21}/src/spawn_cli/models/skill.py +0 -0
  45. {spawn_cli-0.1.20 → spawn_cli-0.1.21}/src/spawn_cli/resources/__init__.py +0 -0
  46. {spawn_cli-0.1.20 → spawn_cli-0.1.21}/src/spawn_cli/resources/default_core_config.yaml +0 -0
  47. {spawn_cli-0.1.20 → spawn_cli-0.1.21}/src/spawn_cli/warnings_display.py +0 -0
  48. {spawn_cli-0.1.20 → spawn_cli-0.1.21}/src/spawn_cli.egg-info/dependency_links.txt +0 -0
  49. {spawn_cli-0.1.20 → spawn_cli-0.1.21}/src/spawn_cli.egg-info/entry_points.txt +0 -0
  50. {spawn_cli-0.1.20 → spawn_cli-0.1.21}/src/spawn_cli.egg-info/requires.txt +0 -0
  51. {spawn_cli-0.1.20 → spawn_cli-0.1.21}/src/spawn_cli.egg-info/top_level.txt +0 -0
  52. {spawn_cli-0.1.20 → spawn_cli-0.1.21}/tests/test_cli.py +0 -0
  53. {spawn_cli-0.1.20 → spawn_cli-0.1.21}/tests/test_warnings_display.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: spawn-cli
3
- Version: 0.1.20
3
+ Version: 0.1.21
4
4
  Summary: A minimal installable Python CLI package.
5
5
  License: MIT
6
6
  Classifier: License :: OSI Approved :: MIT License
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "spawn-cli"
7
- version = "0.1.20"
7
+ version = "0.1.21"
8
8
  description = "A minimal installable Python CLI package."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -33,6 +33,7 @@ CANONICAL_IDE_KEYS: tuple[str, ...] = (
33
33
  "github-copilot",
34
34
  "gemini-cli",
35
35
  "cline",
36
+ "opencode",
36
37
  )
37
38
 
38
39
  SPAWN_GITIGNORE_START = "# spawn:start"
@@ -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",
@@ -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"]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: spawn-cli
3
- Version: 0.1.20
3
+ Version: 0.1.21
4
4
  Summary: A minimal installable Python CLI package.
5
5
  License: MIT
6
6
  Classifier: License :: OSI Approved :: MIT License
@@ -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