spawn-cli 0.1.20__tar.gz → 0.1.22__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.20/src/spawn_cli.egg-info → spawn_cli-0.1.22}/PKG-INFO +30 -4
- {spawn_cli-0.1.20 → spawn_cli-0.1.22}/README.md +31 -5
- {spawn_cli-0.1.20 → spawn_cli-0.1.22}/pyproject.toml +1 -1
- {spawn_cli-0.1.20 → spawn_cli-0.1.22}/src/spawn_cli/core/low_level.py +4 -0
- {spawn_cli-0.1.20 → spawn_cli-0.1.22}/src/spawn_cli/ide/__init__.py +12 -0
- {spawn_cli-0.1.20 → spawn_cli-0.1.22}/src/spawn_cli/ide/_vacancy.py +96 -0
- spawn_cli-0.1.22/src/spawn_cli/ide/hermes.py +107 -0
- spawn_cli-0.1.22/src/spawn_cli/ide/junie.py +141 -0
- spawn_cli-0.1.22/src/spawn_cli/ide/mimo_code.py +177 -0
- spawn_cli-0.1.22/src/spawn_cli/ide/opencode.py +187 -0
- {spawn_cli-0.1.20 → spawn_cli-0.1.22}/src/spawn_cli/ide/registry.py +1 -0
- {spawn_cli-0.1.20 → spawn_cli-0.1.22/src/spawn_cli.egg-info}/PKG-INFO +30 -4
- {spawn_cli-0.1.20 → spawn_cli-0.1.22}/src/spawn_cli.egg-info/SOURCES.txt +4 -0
- {spawn_cli-0.1.20 → spawn_cli-0.1.22}/LICENSE +0 -0
- {spawn_cli-0.1.20 → spawn_cli-0.1.22}/setup.cfg +0 -0
- {spawn_cli-0.1.20 → spawn_cli-0.1.22}/src/spawn_cli/__init__.py +0 -0
- {spawn_cli-0.1.20 → spawn_cli-0.1.22}/src/spawn_cli/cli.py +0 -0
- {spawn_cli-0.1.20 → spawn_cli-0.1.22}/src/spawn_cli/core/__init__.py +0 -0
- {spawn_cli-0.1.20 → spawn_cli-0.1.22}/src/spawn_cli/core/download.py +0 -0
- {spawn_cli-0.1.20 → spawn_cli-0.1.22}/src/spawn_cli/core/errors.py +0 -0
- {spawn_cli-0.1.20 → spawn_cli-0.1.22}/src/spawn_cli/core/high_level.py +0 -0
- {spawn_cli-0.1.20 → spawn_cli-0.1.22}/src/spawn_cli/core/mcp_stdio.py +0 -0
- {spawn_cli-0.1.20 → spawn_cli-0.1.22}/src/spawn_cli/core/scripts.py +0 -0
- {spawn_cli-0.1.20 → spawn_cli-0.1.22}/src/spawn_cli/errors.py +0 -0
- {spawn_cli-0.1.20 → spawn_cli-0.1.22}/src/spawn_cli/ide/_helpers.py +0 -0
- {spawn_cli-0.1.20 → spawn_cli-0.1.22}/src/spawn_cli/ide/_stub.py +0 -0
- {spawn_cli-0.1.20 → spawn_cli-0.1.22}/src/spawn_cli/ide/claude_code.py +0 -0
- {spawn_cli-0.1.20 → spawn_cli-0.1.22}/src/spawn_cli/ide/cline.py +0 -0
- {spawn_cli-0.1.20 → spawn_cli-0.1.22}/src/spawn_cli/ide/codex.py +0 -0
- {spawn_cli-0.1.20 → spawn_cli-0.1.22}/src/spawn_cli/ide/cursor.py +0 -0
- {spawn_cli-0.1.20 → spawn_cli-0.1.22}/src/spawn_cli/ide/gemini_cli.py +0 -0
- {spawn_cli-0.1.20 → spawn_cli-0.1.22}/src/spawn_cli/ide/github_copilot.py +0 -0
- {spawn_cli-0.1.20 → spawn_cli-0.1.22}/src/spawn_cli/ide/mcp_stdio_argv.py +0 -0
- {spawn_cli-0.1.20 → spawn_cli-0.1.22}/src/spawn_cli/ide/windsurf.py +0 -0
- {spawn_cli-0.1.20 → spawn_cli-0.1.22}/src/spawn_cli/io/__init__.py +0 -0
- {spawn_cli-0.1.20 → spawn_cli-0.1.22}/src/spawn_cli/io/json_io.py +0 -0
- {spawn_cli-0.1.20 → spawn_cli-0.1.22}/src/spawn_cli/io/lock.py +0 -0
- {spawn_cli-0.1.20 → spawn_cli-0.1.22}/src/spawn_cli/io/paths.py +0 -0
- {spawn_cli-0.1.20 → spawn_cli-0.1.22}/src/spawn_cli/io/text_io.py +0 -0
- {spawn_cli-0.1.20 → spawn_cli-0.1.22}/src/spawn_cli/io/toml_io.py +0 -0
- {spawn_cli-0.1.20 → spawn_cli-0.1.22}/src/spawn_cli/io/yaml_io.py +0 -0
- {spawn_cli-0.1.20 → spawn_cli-0.1.22}/src/spawn_cli/models/__init__.py +0 -0
- {spawn_cli-0.1.20 → spawn_cli-0.1.22}/src/spawn_cli/models/config.py +0 -0
- {spawn_cli-0.1.20 → spawn_cli-0.1.22}/src/spawn_cli/models/mcp.py +0 -0
- {spawn_cli-0.1.20 → spawn_cli-0.1.22}/src/spawn_cli/models/metadata.py +0 -0
- {spawn_cli-0.1.20 → spawn_cli-0.1.22}/src/spawn_cli/models/navigation.py +0 -0
- {spawn_cli-0.1.20 → spawn_cli-0.1.22}/src/spawn_cli/models/skill.py +0 -0
- {spawn_cli-0.1.20 → spawn_cli-0.1.22}/src/spawn_cli/resources/__init__.py +0 -0
- {spawn_cli-0.1.20 → spawn_cli-0.1.22}/src/spawn_cli/resources/default_core_config.yaml +0 -0
- {spawn_cli-0.1.20 → spawn_cli-0.1.22}/src/spawn_cli/warnings_display.py +0 -0
- {spawn_cli-0.1.20 → spawn_cli-0.1.22}/src/spawn_cli.egg-info/dependency_links.txt +0 -0
- {spawn_cli-0.1.20 → spawn_cli-0.1.22}/src/spawn_cli.egg-info/entry_points.txt +0 -0
- {spawn_cli-0.1.20 → spawn_cli-0.1.22}/src/spawn_cli.egg-info/requires.txt +0 -0
- {spawn_cli-0.1.20 → spawn_cli-0.1.22}/src/spawn_cli.egg-info/top_level.txt +0 -0
- {spawn_cli-0.1.20 → spawn_cli-0.1.22}/tests/test_cli.py +0 -0
- {spawn_cli-0.1.20 → spawn_cli-0.1.22}/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.
|
|
3
|
+
Version: 0.1.22
|
|
4
4
|
Summary: A minimal installable Python CLI package.
|
|
5
5
|
License: MIT
|
|
6
6
|
Classifier: License :: OSI Approved :: MIT License
|
|
@@ -240,14 +240,40 @@ read-required:
|
|
|
240
240
|
|
|
241
241
|
Then run **`spawn refresh`** so skills and entry points pick up the hint.
|
|
242
242
|
|
|
243
|
-
|
|
243
|
+
### Supported IDEs
|
|
244
244
|
|
|
245
|
-
|
|
245
|
+
| IDE (key) | Skills | MCP | Agent ignore | Entry point | Detection markers |
|
|
246
|
+
| --- | --- | --- | --- | --- | --- |
|
|
247
|
+
| Cursor (`cursor`) | `.cursor/skills/` | `.cursor/mcp.json` | `.cursorignore` | `AGENTS.md` | `.cursor/` |
|
|
248
|
+
| Codex (`codex`) | `.agents/skills/` | `.codex/config.toml` | `config.toml` permissions | `AGENTS.md` | `.codex/`, `.agents/` |
|
|
249
|
+
| Claude Code (`claude-code`) | `.claude/skills/` | `.mcp.json` | `.claude/settings.json` | `CLAUDE.md` | `.claude/`, `CLAUDE.md` |
|
|
250
|
+
| Windsurf (`windsurf`) | `.windsurf/skills/` | — | `.codeiumignore` | `AGENTS.md` | `.windsurf/`, `.codeiumignore` |
|
|
251
|
+
| GitHub Copilot (`github-copilot`) | `.github/skills/` | `.vscode/mcp.json` | — | `.github/copilot-instructions.md`, `AGENTS.md` | `.github/`, `.vscode/` |
|
|
252
|
+
| Gemini CLI (`gemini-cli`) | `.gemini/skills/` | `.gemini/settings.json` | `.geminiignore` | `GEMINI.md` | `.gemini/`, `GEMINI.md` |
|
|
253
|
+
| Cline (`cline`) | `.cline/skills/` | — | `.clineignore` | `AGENTS.md` | `.cline/`, `.clinerules`, `.clineignore` |
|
|
254
|
+
| OpenCode (`opencode`) | `.opencode/skills/` | `opencode.json` | `opencode.json` permissions | `AGENTS.md` | `.opencode/`, `opencode.json`, `opencode.jsonc` |
|
|
255
|
+
| Mimo Code (`mimo-code`) | `.mimocode/skills/` | `.mimocode/mimocode.json` | `mimocode.json` permissions | `AGENTS.md` | `.mimocode/` |
|
|
256
|
+
| Hermes (`hermes`) | `.hermes/skills/` | — | — | `.hermes.md` | `.hermes/`, `.hermes.md`, `HERMES.md` |
|
|
257
|
+
| JetBrains Junie (`junie`) | `.junie/skills/` | `.junie/mcp/mcp.json` | `.aiignore` | `AGENTS.md` | `.junie/` |
|
|
246
258
|
|
|
247
|
-
|
|
259
|
+
**Legend:**
|
|
260
|
+
|
|
261
|
+
- **Skills** — project directory where spawn renders `SKILL.md` per skill (`native` capability; frontmatter `name` matches the directory name).
|
|
262
|
+
- **MCP** — project-scope config file where spawn merges extension MCP servers. "—" means the tool keeps MCP global-only (e.g. `~/.hermes/config.yaml`); spawn warns (`SpawnWarning`) and writes nothing.
|
|
263
|
+
- **Agent ignore** — where spawn merges agent-ignore globs: a dedicated ignore file, or permissions entries in the project config (managed block / spawn-owned entries only; removal never touches user rules). "—" means no upstream ignore mechanism; spawn warns and skips.
|
|
264
|
+
- **Entry point** — file(s) receiving the Spawn-managed instruction block (`<!-- spawn:start -->` ... `<!-- spawn:end -->`); user content around the block is preserved.
|
|
265
|
+
- **Detection markers** — files/directories that make `spawn ide list-supported-ides` (and auto-detection) mark the IDE as used in the repo.
|
|
266
|
+
|
|
267
|
+
Full per-IDE contracts (config shapes, ignore semantics, caveats): [spec/design/ide-adapters.md](spec/design/ide-adapters.md).
|
|
248
268
|
|
|
249
269
|
P.S. Below is a diagram that might help you understand how Spawn works.
|
|
250
270
|
|
|
251
271
|
<p align="center">
|
|
252
272
|
<img src="logo/Scheme.png" alt="How Spawn works" />
|
|
253
273
|
</p>
|
|
274
|
+
|
|
275
|
+
---
|
|
276
|
+
|
|
277
|
+
## License
|
|
278
|
+
|
|
279
|
+
MIT — see [LICENSE](LICENSE).
|
|
@@ -223,14 +223,40 @@ read-required:
|
|
|
223
223
|
|
|
224
224
|
Then run **`spawn refresh`** so skills and entry points pick up the hint.
|
|
225
225
|
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
226
|
+
### Supported IDEs
|
|
227
|
+
|
|
228
|
+
| IDE (key) | Skills | MCP | Agent ignore | Entry point | Detection markers |
|
|
229
|
+
| --- | --- | --- | --- | --- | --- |
|
|
230
|
+
| Cursor (`cursor`) | `.cursor/skills/` | `.cursor/mcp.json` | `.cursorignore` | `AGENTS.md` | `.cursor/` |
|
|
231
|
+
| Codex (`codex`) | `.agents/skills/` | `.codex/config.toml` | `config.toml` permissions | `AGENTS.md` | `.codex/`, `.agents/` |
|
|
232
|
+
| Claude Code (`claude-code`) | `.claude/skills/` | `.mcp.json` | `.claude/settings.json` | `CLAUDE.md` | `.claude/`, `CLAUDE.md` |
|
|
233
|
+
| Windsurf (`windsurf`) | `.windsurf/skills/` | — | `.codeiumignore` | `AGENTS.md` | `.windsurf/`, `.codeiumignore` |
|
|
234
|
+
| GitHub Copilot (`github-copilot`) | `.github/skills/` | `.vscode/mcp.json` | — | `.github/copilot-instructions.md`, `AGENTS.md` | `.github/`, `.vscode/` |
|
|
235
|
+
| Gemini CLI (`gemini-cli`) | `.gemini/skills/` | `.gemini/settings.json` | `.geminiignore` | `GEMINI.md` | `.gemini/`, `GEMINI.md` |
|
|
236
|
+
| Cline (`cline`) | `.cline/skills/` | — | `.clineignore` | `AGENTS.md` | `.cline/`, `.clinerules`, `.clineignore` |
|
|
237
|
+
| OpenCode (`opencode`) | `.opencode/skills/` | `opencode.json` | `opencode.json` permissions | `AGENTS.md` | `.opencode/`, `opencode.json`, `opencode.jsonc` |
|
|
238
|
+
| Mimo Code (`mimo-code`) | `.mimocode/skills/` | `.mimocode/mimocode.json` | `mimocode.json` permissions | `AGENTS.md` | `.mimocode/` |
|
|
239
|
+
| Hermes (`hermes`) | `.hermes/skills/` | — | — | `.hermes.md` | `.hermes/`, `.hermes.md`, `HERMES.md` |
|
|
240
|
+
| JetBrains Junie (`junie`) | `.junie/skills/` | `.junie/mcp/mcp.json` | `.aiignore` | `AGENTS.md` | `.junie/` |
|
|
241
|
+
|
|
242
|
+
**Legend:**
|
|
243
|
+
|
|
244
|
+
- **Skills** — project directory where spawn renders `SKILL.md` per skill (`native` capability; frontmatter `name` matches the directory name).
|
|
245
|
+
- **MCP** — project-scope config file where spawn merges extension MCP servers. "—" means the tool keeps MCP global-only (e.g. `~/.hermes/config.yaml`); spawn warns (`SpawnWarning`) and writes nothing.
|
|
246
|
+
- **Agent ignore** — where spawn merges agent-ignore globs: a dedicated ignore file, or permissions entries in the project config (managed block / spawn-owned entries only; removal never touches user rules). "—" means no upstream ignore mechanism; spawn warns and skips.
|
|
247
|
+
- **Entry point** — file(s) receiving the Spawn-managed instruction block (`<!-- spawn:start -->` ... `<!-- spawn:end -->`); user content around the block is preserved.
|
|
248
|
+
- **Detection markers** — files/directories that make `spawn ide list-supported-ides` (and auto-detection) mark the IDE as used in the repo.
|
|
249
|
+
|
|
250
|
+
Full per-IDE contracts (config shapes, ignore semantics, caveats): [spec/design/ide-adapters.md](spec/design/ide-adapters.md).
|
|
231
251
|
|
|
232
252
|
P.S. Below is a diagram that might help you understand how Spawn works.
|
|
233
253
|
|
|
234
254
|
<p align="center">
|
|
235
255
|
<img src="logo/Scheme.png" alt="How Spawn works" />
|
|
236
256
|
</p>
|
|
257
|
+
|
|
258
|
+
---
|
|
259
|
+
|
|
260
|
+
## License
|
|
261
|
+
|
|
262
|
+
MIT — see [LICENSE](LICENSE).
|
|
@@ -9,8 +9,16 @@ 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
|
|
13
|
+
from . import mimo_code # noqa: F401
|
|
14
|
+
from . import hermes # noqa: F401
|
|
15
|
+
from . import junie # noqa: F401
|
|
12
16
|
from ._stub import StubAdapter
|
|
13
17
|
from .cline import ClineAdapter
|
|
18
|
+
from .opencode import OpenCodeAdapter
|
|
19
|
+
from .mimo_code import MimoCodeAdapter
|
|
20
|
+
from .hermes import HermesAdapter
|
|
21
|
+
from .junie import JunieAdapter
|
|
14
22
|
from .claude_code import ClaudeCodeAdapter
|
|
15
23
|
from .codex import CodexAdapter
|
|
16
24
|
from .cursor import CursorAdapter
|
|
@@ -45,6 +53,10 @@ from .registry import (
|
|
|
45
53
|
__all__ = [
|
|
46
54
|
"ALIASES",
|
|
47
55
|
"ClineAdapter",
|
|
56
|
+
"OpenCodeAdapter",
|
|
57
|
+
"MimoCodeAdapter",
|
|
58
|
+
"HermesAdapter",
|
|
59
|
+
"JunieAdapter",
|
|
48
60
|
"ClaudeCodeAdapter",
|
|
49
61
|
"CodexAdapter",
|
|
50
62
|
"CursorAdapter",
|
|
@@ -226,6 +226,66 @@ 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
|
+
|
|
264
|
+
def finalize_hermes_repo(target_root: Path) -> None:
|
|
265
|
+
"""Hermes adapter: `.hermes/skills` dotdir; drop when vacant."""
|
|
266
|
+
finalize_standard_dotdir_skills_and_mcp(
|
|
267
|
+
target_root,
|
|
268
|
+
".hermes",
|
|
269
|
+
allow_delete_entire=True,
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def finalize_junie_repo(target_root: Path) -> None:
|
|
274
|
+
"""Junie adapter: `.junie/{skills,mcp/mcp.json}`; drop parts when vacant, keep user content."""
|
|
275
|
+
junie_root = target_root / ".junie"
|
|
276
|
+
if not junie_root.is_dir():
|
|
277
|
+
return
|
|
278
|
+
try_unlink_empty_mcp_mcp_servers(junie_root / "mcp" / "mcp.json")
|
|
279
|
+
prune_empty_directories_under(junie_root)
|
|
280
|
+
if ide_dotdir_is_entirely_removable(junie_root, allow_delete_entire=True):
|
|
281
|
+
shutil.rmtree(junie_root, ignore_errors=True)
|
|
282
|
+
ignore = target_root / ".aiignore"
|
|
283
|
+
if ignore.is_file():
|
|
284
|
+
clear_split_agent_ignore_file(ignore)
|
|
285
|
+
if not ignore.read_text(encoding="utf-8").strip():
|
|
286
|
+
ignore.unlink(missing_ok=True)
|
|
287
|
+
|
|
288
|
+
|
|
229
289
|
def finalize_cline_repo(target_root: Path) -> None:
|
|
230
290
|
"""Cline adapter: `.cline/skills` dotdir and root `.clineignore`; drop both when vacant."""
|
|
231
291
|
finalize_standard_dotdir_skills_and_mcp(
|
|
@@ -240,10 +300,46 @@ def finalize_cline_repo(target_root: Path) -> None:
|
|
|
240
300
|
ignore.unlink(missing_ok=True)
|
|
241
301
|
|
|
242
302
|
|
|
303
|
+
def _mimo_config_vacant(path: Path) -> bool:
|
|
304
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
305
|
+
if not isinstance(data, dict):
|
|
306
|
+
return False # user config in an unexpected shape: keep untouched
|
|
307
|
+
if data == {}:
|
|
308
|
+
return True
|
|
309
|
+
for key, value in data.items():
|
|
310
|
+
if key in ("mcp", "permission"):
|
|
311
|
+
if value: # non-empty -> user content or live servers
|
|
312
|
+
return False
|
|
313
|
+
elif json_value_nonempty(value): # unknown user key with substance
|
|
314
|
+
return False
|
|
315
|
+
return True
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def finalize_mimo_code_repo(target_root: Path) -> None:
|
|
319
|
+
"""Mimo Code adapter: `.mimocode/mimocode.json` (keys mcp, permission) and `.mimocode/skills`; drop when vacant."""
|
|
320
|
+
config = target_root / ".mimocode" / "mimocode.json"
|
|
321
|
+
if config.is_file():
|
|
322
|
+
try:
|
|
323
|
+
vacant = _mimo_config_vacant(config)
|
|
324
|
+
except json.JSONDecodeError:
|
|
325
|
+
vacant = False # keep unparseable user config untouched
|
|
326
|
+
if vacant:
|
|
327
|
+
config.unlink()
|
|
328
|
+
skills_dir = target_root / ".mimocode" / "skills"
|
|
329
|
+
if skills_dir.is_dir() and not dir_has_any_file(skills_dir):
|
|
330
|
+
prune_empty_directories_under(target_root / ".mimocode")
|
|
331
|
+
if (target_root / ".mimocode").is_dir() and not dir_has_any_file(target_root / ".mimocode"):
|
|
332
|
+
(target_root / ".mimocode").rmdir()
|
|
333
|
+
|
|
334
|
+
|
|
243
335
|
__all__ = [
|
|
244
336
|
"dir_has_any_file",
|
|
245
337
|
"finalize_claude_repo",
|
|
246
338
|
"finalize_cline_repo",
|
|
339
|
+
"finalize_hermes_repo",
|
|
340
|
+
"finalize_junie_repo",
|
|
341
|
+
"finalize_mimo_code_repo",
|
|
342
|
+
"finalize_opencode_repo",
|
|
247
343
|
"finalize_codex_repo",
|
|
248
344
|
"finalize_github_copilot_repo",
|
|
249
345
|
"finalize_standard_dotdir_skills_and_mcp",
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Hermes Agent (Nous Research) IDE adapter.
|
|
2
|
+
|
|
3
|
+
Project scope: skills at <repo>/.hermes/skills/<name>/SKILL.md and a
|
|
4
|
+
spawn-managed block in root `.hermes.md` (Hermes highest-priority project
|
|
5
|
+
context file; priority: .hermes.md > AGENTS.override.md > AGENTS.md).
|
|
6
|
+
MCP config and agent ignore are global-only in Hermes
|
|
7
|
+
(~/.hermes/config.yaml; no ignore file) -> unsupported here.
|
|
8
|
+
Project skills require `hermes skills trust` before Hermes loads them.
|
|
9
|
+
`.agents/skills` is intentionally NOT used (owned by the Codex adapter).
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import warnings
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from spawn_cli.errors import SpawnWarning
|
|
18
|
+
from spawn_cli.ide import _vacancy as _vac
|
|
19
|
+
from spawn_cli.ide._helpers import rewrite_managed_block
|
|
20
|
+
from spawn_cli.ide.registry import (
|
|
21
|
+
DetectResult,
|
|
22
|
+
IdeAdapter,
|
|
23
|
+
IdeCapabilities,
|
|
24
|
+
normalize_skill_name,
|
|
25
|
+
register,
|
|
26
|
+
render_skill_md,
|
|
27
|
+
)
|
|
28
|
+
from spawn_cli.models.mcp import NormalizedMcp
|
|
29
|
+
from spawn_cli.models.skill import SkillMetadata
|
|
30
|
+
|
|
31
|
+
CAPS = IdeCapabilities(
|
|
32
|
+
skills="native",
|
|
33
|
+
mcp="unsupported",
|
|
34
|
+
agent_ignore="unsupported",
|
|
35
|
+
entry_point="hermes-md",
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class HermesAdapter(IdeAdapter):
|
|
40
|
+
key = "hermes"
|
|
41
|
+
|
|
42
|
+
def detect(self, target_root: Path) -> DetectResult:
|
|
43
|
+
used = (
|
|
44
|
+
(target_root / ".hermes").is_dir()
|
|
45
|
+
or (target_root / ".hermes.md").is_file()
|
|
46
|
+
or (target_root / "HERMES.md").is_file()
|
|
47
|
+
)
|
|
48
|
+
return DetectResult(used_in_repo=used, capabilities=CAPS)
|
|
49
|
+
|
|
50
|
+
def add_skills(self, target_root: Path, skill_metadata: list[SkillMetadata]) -> list[dict]:
|
|
51
|
+
results = []
|
|
52
|
+
for skill in skill_metadata:
|
|
53
|
+
name = normalize_skill_name(skill.name)
|
|
54
|
+
skill_dir = target_root / ".hermes" / "skills" / name
|
|
55
|
+
skill_dir.mkdir(parents=True, exist_ok=True)
|
|
56
|
+
skill_path = skill_dir / "SKILL.md"
|
|
57
|
+
if skill_path.exists():
|
|
58
|
+
warnings.warn(f"Overwriting existing rendered skill: {skill_path}")
|
|
59
|
+
# Hermes requires frontmatter name == directory name.
|
|
60
|
+
rendered = render_skill_md(skill.model_copy(update={"name": name}))
|
|
61
|
+
skill_path.write_text(rendered, encoding="utf-8")
|
|
62
|
+
rel = skill_path.relative_to(target_root).as_posix()
|
|
63
|
+
results.append({"skill": skill.name, "path": rel})
|
|
64
|
+
return results
|
|
65
|
+
|
|
66
|
+
def remove_skills(self, target_root: Path, rendered_paths: list[dict]) -> None:
|
|
67
|
+
for entry in rendered_paths:
|
|
68
|
+
p = target_root / entry["path"]
|
|
69
|
+
if p.exists():
|
|
70
|
+
p.unlink()
|
|
71
|
+
if p.parent.exists() and not any(p.parent.iterdir()):
|
|
72
|
+
p.parent.rmdir()
|
|
73
|
+
|
|
74
|
+
def add_mcp(self, target_root: Path, normalized_mcp: NormalizedMcp) -> list[str]:
|
|
75
|
+
del normalized_mcp
|
|
76
|
+
del target_root
|
|
77
|
+
warnings.warn(
|
|
78
|
+
"Hermes has no project-scope MCP settings; "
|
|
79
|
+
"servers stay configured globally in ~/.hermes/config.yaml",
|
|
80
|
+
SpawnWarning,
|
|
81
|
+
)
|
|
82
|
+
return []
|
|
83
|
+
|
|
84
|
+
def remove_mcp(self, target_root: Path, rendered_mcp_names: list[str]) -> None:
|
|
85
|
+
del target_root, rendered_mcp_names
|
|
86
|
+
warnings.warn("MCP is not managed by spawn for hermes; nothing to remove", SpawnWarning)
|
|
87
|
+
|
|
88
|
+
def add_agent_ignore(self, target_root: Path, globs: list[str]) -> None:
|
|
89
|
+
del target_root, globs
|
|
90
|
+
warnings.warn("hermes: agent ignore is unsupported", SpawnWarning)
|
|
91
|
+
|
|
92
|
+
def remove_agent_ignore(self, target_root: Path, globs: list[str]) -> None:
|
|
93
|
+
del target_root, globs
|
|
94
|
+
warnings.warn("hermes: agent ignore is unsupported", SpawnWarning)
|
|
95
|
+
|
|
96
|
+
def rewrite_entry_point(self, target_root: Path, prompt: str) -> str:
|
|
97
|
+
ep = target_root / ".hermes.md"
|
|
98
|
+
rewrite_managed_block(ep, prompt)
|
|
99
|
+
return ep.relative_to(target_root).as_posix()
|
|
100
|
+
|
|
101
|
+
def finalize_repo_after_ide_removed(self, target_root: Path) -> None:
|
|
102
|
+
_vac.finalize_hermes_repo(target_root)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
register(HermesAdapter())
|
|
106
|
+
|
|
107
|
+
__all__ = ["HermesAdapter"]
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""JetBrains Junie IDE adapter: .junie/skills, .junie/mcp/mcp.json, .aiignore, AGENTS.md."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import warnings
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from spawn_cli.errors import SpawnWarning
|
|
10
|
+
from spawn_cli.ide import _vacancy as _vac
|
|
11
|
+
from spawn_cli.ide.mcp_stdio_argv import mcp_stdio_argv
|
|
12
|
+
from spawn_cli.ide._helpers import (
|
|
13
|
+
clear_split_agent_ignore_file,
|
|
14
|
+
remove_ignore_block,
|
|
15
|
+
rewrite_core_agent_ignore_region,
|
|
16
|
+
rewrite_extension_agent_ignore_region,
|
|
17
|
+
rewrite_ignore_block,
|
|
18
|
+
)
|
|
19
|
+
from spawn_cli.ide.registry import (
|
|
20
|
+
DetectResult,
|
|
21
|
+
IdeAdapter,
|
|
22
|
+
IdeCapabilities,
|
|
23
|
+
register,
|
|
24
|
+
normalize_skill_name,
|
|
25
|
+
render_skill_md,
|
|
26
|
+
rewrite_managed_block,
|
|
27
|
+
)
|
|
28
|
+
from spawn_cli.models.mcp import McpServer, NormalizedMcp
|
|
29
|
+
from spawn_cli.models.skill import SkillMetadata
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _build_mcp_server_entry(server: McpServer) -> dict:
|
|
33
|
+
"""Convert McpServer to .junie/mcp/mcp.json entry (mcpServers format)."""
|
|
34
|
+
transport = server.transport
|
|
35
|
+
if transport.type == "stdio":
|
|
36
|
+
if server.spawn_stdio_proxy:
|
|
37
|
+
entry = {"command": "spawn", "args": mcp_stdio_argv(server.extension, server.name)}
|
|
38
|
+
else:
|
|
39
|
+
entry = {"command": transport.command, "args": transport.args}
|
|
40
|
+
elif transport.type in ("streamable-http", "sse"):
|
|
41
|
+
entry = {"type": transport.type, "url": transport.url}
|
|
42
|
+
if transport.headers:
|
|
43
|
+
entry["headers"] = {k: f"${{{v}}}" for k, v in transport.headers.items()}
|
|
44
|
+
else:
|
|
45
|
+
entry = {"command": transport.command, "args": transport.args}
|
|
46
|
+
if server.env:
|
|
47
|
+
entry["env"] = {
|
|
48
|
+
k: f"${{{k}}}" if v.secret else v.value or f"${{{k}}}"
|
|
49
|
+
for k, v in server.env.items()
|
|
50
|
+
}
|
|
51
|
+
return entry
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class JunieAdapter(IdeAdapter):
|
|
55
|
+
key = "junie"
|
|
56
|
+
|
|
57
|
+
def detect(self, target_root: Path) -> DetectResult:
|
|
58
|
+
used = (target_root / ".junie").exists()
|
|
59
|
+
return DetectResult(
|
|
60
|
+
used_in_repo=used,
|
|
61
|
+
capabilities=IdeCapabilities(
|
|
62
|
+
skills="native",
|
|
63
|
+
mcp="project",
|
|
64
|
+
agent_ignore="native",
|
|
65
|
+
entry_point="agents-md",
|
|
66
|
+
),
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
def add_skills(self, target_root: Path, skill_metadata: list[SkillMetadata]) -> list[dict]:
|
|
70
|
+
results = []
|
|
71
|
+
for skill in skill_metadata:
|
|
72
|
+
name = normalize_skill_name(skill.name)
|
|
73
|
+
skill_dir = target_root / ".junie" / "skills" / name
|
|
74
|
+
skill_dir.mkdir(parents=True, exist_ok=True)
|
|
75
|
+
skill_path = skill_dir / "SKILL.md"
|
|
76
|
+
if skill_path.exists():
|
|
77
|
+
warnings.warn(f"Overwriting existing rendered skill: {skill_path}", SpawnWarning)
|
|
78
|
+
skill_path.write_text(render_skill_md(skill.model_copy(update={"name": name})), encoding="utf-8")
|
|
79
|
+
rel = skill_path.relative_to(target_root).as_posix()
|
|
80
|
+
results.append({"skill": skill.name, "path": rel})
|
|
81
|
+
return results
|
|
82
|
+
|
|
83
|
+
def remove_skills(self, target_root: Path, rendered_paths: list[dict]) -> None:
|
|
84
|
+
for entry in rendered_paths:
|
|
85
|
+
p = target_root / entry["path"]
|
|
86
|
+
if p.exists():
|
|
87
|
+
p.unlink()
|
|
88
|
+
if p.parent.exists() and not any(p.parent.iterdir()):
|
|
89
|
+
p.parent.rmdir()
|
|
90
|
+
|
|
91
|
+
def add_mcp(self, target_root: Path, normalized_mcp: NormalizedMcp) -> list[str]:
|
|
92
|
+
"""Merge Spawn-owned servers into .junie/mcp/mcp.json (mcpServers format)."""
|
|
93
|
+
mcp_path = target_root / ".junie" / "mcp" / "mcp.json"
|
|
94
|
+
mcp_path.parent.mkdir(parents=True, exist_ok=True)
|
|
95
|
+
data = json.loads(mcp_path.read_text(encoding="utf-8")) if mcp_path.exists() else {}
|
|
96
|
+
data.setdefault("mcpServers", {})
|
|
97
|
+
rendered_names = []
|
|
98
|
+
for server in normalized_mcp.servers:
|
|
99
|
+
entry = _build_mcp_server_entry(server)
|
|
100
|
+
data["mcpServers"][server.name] = entry
|
|
101
|
+
rendered_names.append(server.name)
|
|
102
|
+
mcp_path.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
|
103
|
+
return rendered_names
|
|
104
|
+
|
|
105
|
+
def remove_mcp(self, target_root: Path, rendered_mcp_names: list[str]) -> None:
|
|
106
|
+
"""Remove named entries from .junie/mcp/mcp.json."""
|
|
107
|
+
mcp_path = target_root / ".junie" / "mcp" / "mcp.json"
|
|
108
|
+
if not mcp_path.exists():
|
|
109
|
+
return
|
|
110
|
+
data = json.loads(mcp_path.read_text(encoding="utf-8"))
|
|
111
|
+
for name in rendered_mcp_names:
|
|
112
|
+
data.get("mcpServers", {}).pop(name, None)
|
|
113
|
+
mcp_path.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
|
114
|
+
|
|
115
|
+
def add_agent_ignore(self, target_root: Path, globs: list[str]) -> None:
|
|
116
|
+
rewrite_ignore_block(target_root / ".aiignore", globs)
|
|
117
|
+
|
|
118
|
+
def remove_agent_ignore(self, target_root: Path, globs: list[str]) -> None:
|
|
119
|
+
remove_ignore_block(target_root / ".aiignore", globs)
|
|
120
|
+
|
|
121
|
+
def rewrite_core_agent_ignore(self, target_root: Path, globs: list[str]) -> None:
|
|
122
|
+
rewrite_core_agent_ignore_region(target_root / ".aiignore", globs)
|
|
123
|
+
|
|
124
|
+
def rewrite_extension_agent_ignore(self, target_root: Path, globs: list[str]) -> None:
|
|
125
|
+
rewrite_extension_agent_ignore_region(target_root / ".aiignore", globs)
|
|
126
|
+
|
|
127
|
+
def clear_spawn_agent_ignore(self, target_root: Path) -> None:
|
|
128
|
+
clear_split_agent_ignore_file(target_root / ".aiignore")
|
|
129
|
+
|
|
130
|
+
def rewrite_entry_point(self, target_root: Path, prompt: str) -> str:
|
|
131
|
+
ep = target_root / "AGENTS.md"
|
|
132
|
+
rewrite_managed_block(ep, prompt)
|
|
133
|
+
return str(ep.relative_to(target_root))
|
|
134
|
+
|
|
135
|
+
def finalize_repo_after_ide_removed(self, target_root: Path) -> None:
|
|
136
|
+
_vac.finalize_junie_repo(target_root)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
register(JunieAdapter())
|
|
140
|
+
|
|
141
|
+
__all__ = ["JunieAdapter"]
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"""Mimo Code adapter: .mimocode/skills, .mimocode/mimocode.json (mcp + permission), AGENTS.md."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import warnings
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from spawn_cli.core import low_level as ll
|
|
10
|
+
from spawn_cli.errors import SpawnError, SpawnWarning
|
|
11
|
+
from spawn_cli.ide import _vacancy as _vac
|
|
12
|
+
from spawn_cli.ide.mcp_stdio_argv import mcp_stdio_argv
|
|
13
|
+
from spawn_cli.ide.registry import (
|
|
14
|
+
DetectResult,
|
|
15
|
+
IdeAdapter,
|
|
16
|
+
IdeCapabilities,
|
|
17
|
+
normalize_skill_name,
|
|
18
|
+
register,
|
|
19
|
+
render_skill_md,
|
|
20
|
+
rewrite_managed_block,
|
|
21
|
+
)
|
|
22
|
+
from spawn_cli.models.mcp import McpServer, NormalizedMcp
|
|
23
|
+
from spawn_cli.models.skill import SkillMetadata
|
|
24
|
+
|
|
25
|
+
_CONFIG = Path(".mimocode") / "mimocode.json"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _load_config(target_root: Path) -> dict:
|
|
29
|
+
path = target_root / _CONFIG
|
|
30
|
+
if not path.exists():
|
|
31
|
+
if (target_root / ".mimocode" / "mimocode.jsonc").exists():
|
|
32
|
+
warnings.warn(
|
|
33
|
+
"Mimo Code: Spawn manages .mimocode/mimocode.json; existing mimocode.jsonc is not merged",
|
|
34
|
+
SpawnWarning,
|
|
35
|
+
)
|
|
36
|
+
return {}
|
|
37
|
+
try:
|
|
38
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
39
|
+
except json.JSONDecodeError as exc:
|
|
40
|
+
raise SpawnError(f"Mimo Code config is not valid JSON: {path} ({exc})") from exc
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _save_config(target_root: Path, data: dict) -> None:
|
|
44
|
+
path = target_root / _CONFIG
|
|
45
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
46
|
+
path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _build_mcp_entry(server: McpServer) -> dict:
|
|
50
|
+
transport = server.transport
|
|
51
|
+
if transport.type == "stdio":
|
|
52
|
+
if server.spawn_stdio_proxy:
|
|
53
|
+
entry = {
|
|
54
|
+
"type": "local",
|
|
55
|
+
"command": ["spawn"] + mcp_stdio_argv(server.extension, server.name),
|
|
56
|
+
}
|
|
57
|
+
else:
|
|
58
|
+
entry = {"type": "local", "command": [transport.command, *list(transport.args)]}
|
|
59
|
+
else: # streamable-http and sse both render as remote
|
|
60
|
+
entry = {"type": "remote", "url": transport.url}
|
|
61
|
+
if transport.headers:
|
|
62
|
+
entry["headers"] = {k: f"{{env:{v}}}" for k, v in transport.headers.items()}
|
|
63
|
+
entry["enabled"] = True
|
|
64
|
+
if server.env:
|
|
65
|
+
entry["environment"] = {
|
|
66
|
+
k: f"{{env:{k}}}" if v.secret else (v.value or f"{{env:{k}}}")
|
|
67
|
+
for k, v in server.env.items()
|
|
68
|
+
}
|
|
69
|
+
return entry
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class MimoCodeAdapter(IdeAdapter):
|
|
73
|
+
key = "mimo-code"
|
|
74
|
+
|
|
75
|
+
def detect(self, target_root: Path) -> DetectResult:
|
|
76
|
+
used = (target_root / ".mimocode").exists()
|
|
77
|
+
return DetectResult(
|
|
78
|
+
used_in_repo=used,
|
|
79
|
+
capabilities=IdeCapabilities(
|
|
80
|
+
skills="native",
|
|
81
|
+
mcp="project",
|
|
82
|
+
agent_ignore="project",
|
|
83
|
+
entry_point="agents-md",
|
|
84
|
+
),
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
def add_skills(self, target_root: Path, skill_metadata: list[SkillMetadata]) -> list[dict]:
|
|
88
|
+
results = []
|
|
89
|
+
for skill in skill_metadata:
|
|
90
|
+
name = normalize_skill_name(skill.name)
|
|
91
|
+
skill_dir = target_root / ".mimocode" / "skills" / name
|
|
92
|
+
skill_dir.mkdir(parents=True, exist_ok=True)
|
|
93
|
+
skill_path = skill_dir / "SKILL.md"
|
|
94
|
+
if skill_path.exists():
|
|
95
|
+
warnings.warn(f"Overwriting existing rendered skill: {skill_path}", SpawnWarning)
|
|
96
|
+
# Mimo Code requires frontmatter name == directory name.
|
|
97
|
+
rendered = render_skill_md(skill.model_copy(update={"name": name}))
|
|
98
|
+
skill_path.write_text(rendered, encoding="utf-8")
|
|
99
|
+
results.append({"skill": skill.name, "path": skill_path.relative_to(target_root).as_posix()})
|
|
100
|
+
return results
|
|
101
|
+
|
|
102
|
+
def remove_skills(self, target_root: Path, rendered_paths: list[dict]) -> None:
|
|
103
|
+
for entry in rendered_paths:
|
|
104
|
+
p = target_root / entry["path"]
|
|
105
|
+
if p.exists():
|
|
106
|
+
p.unlink()
|
|
107
|
+
if p.parent.exists() and not any(p.parent.iterdir()):
|
|
108
|
+
p.parent.rmdir()
|
|
109
|
+
|
|
110
|
+
def add_mcp(self, target_root: Path, normalized_mcp: NormalizedMcp) -> list[str]:
|
|
111
|
+
data = _load_config(target_root)
|
|
112
|
+
mcp = data.setdefault("mcp", {})
|
|
113
|
+
for server in normalized_mcp.servers:
|
|
114
|
+
mcp[server.name] = _build_mcp_entry(server)
|
|
115
|
+
_save_config(target_root, data)
|
|
116
|
+
return [s.name for s in normalized_mcp.servers]
|
|
117
|
+
|
|
118
|
+
def remove_mcp(self, target_root: Path, rendered_mcp_names: list[str]) -> None:
|
|
119
|
+
if not (target_root / _CONFIG).exists():
|
|
120
|
+
return
|
|
121
|
+
data = _load_config(target_root)
|
|
122
|
+
mcp = data.get("mcp")
|
|
123
|
+
if isinstance(mcp, dict):
|
|
124
|
+
for name in rendered_mcp_names:
|
|
125
|
+
mcp.pop(name, None)
|
|
126
|
+
if not mcp:
|
|
127
|
+
data.pop("mcp", None)
|
|
128
|
+
_save_config(target_root, data)
|
|
129
|
+
|
|
130
|
+
def add_agent_ignore(self, target_root: Path, globs: list[str]) -> None:
|
|
131
|
+
data = _load_config(target_root)
|
|
132
|
+
perms = data.setdefault("permission", {})
|
|
133
|
+
for section in ("read", "edit"):
|
|
134
|
+
rules = perms.setdefault(section, {})
|
|
135
|
+
if not isinstance(rules, dict):
|
|
136
|
+
rules = {}
|
|
137
|
+
perms[section] = rules
|
|
138
|
+
for glob in globs:
|
|
139
|
+
rules[glob] = "deny"
|
|
140
|
+
_save_config(target_root, data)
|
|
141
|
+
|
|
142
|
+
def remove_agent_ignore(self, target_root: Path, globs: list[str]) -> None:
|
|
143
|
+
if not (target_root / _CONFIG).exists():
|
|
144
|
+
return
|
|
145
|
+
data = _load_config(target_root)
|
|
146
|
+
perms = data.get("permission")
|
|
147
|
+
if not isinstance(perms, dict):
|
|
148
|
+
return
|
|
149
|
+
drop = set(globs)
|
|
150
|
+
for section in ("read", "edit"):
|
|
151
|
+
rules = perms.get(section)
|
|
152
|
+
if isinstance(rules, dict):
|
|
153
|
+
for glob in drop:
|
|
154
|
+
rules.pop(glob, None)
|
|
155
|
+
if not rules:
|
|
156
|
+
perms.pop(section, None)
|
|
157
|
+
if not perms:
|
|
158
|
+
data.pop("permission", None)
|
|
159
|
+
_save_config(target_root, data)
|
|
160
|
+
|
|
161
|
+
def clear_spawn_agent_ignore(self, target_root: Path) -> None:
|
|
162
|
+
recorded = ll.get_agent_ignore_list(target_root, self.key)
|
|
163
|
+
if recorded:
|
|
164
|
+
self.remove_agent_ignore(target_root, recorded)
|
|
165
|
+
|
|
166
|
+
def rewrite_entry_point(self, target_root: Path, prompt: str) -> str:
|
|
167
|
+
ep = target_root / "AGENTS.md"
|
|
168
|
+
rewrite_managed_block(ep, prompt)
|
|
169
|
+
return ep.relative_to(target_root).as_posix()
|
|
170
|
+
|
|
171
|
+
def finalize_repo_after_ide_removed(self, target_root: Path) -> None:
|
|
172
|
+
_vac.finalize_mimo_code_repo(target_root)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
register(MimoCodeAdapter())
|
|
176
|
+
|
|
177
|
+
__all__ = ["MimoCodeAdapter"]
|
|
@@ -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.
|
|
3
|
+
Version: 0.1.22
|
|
4
4
|
Summary: A minimal installable Python CLI package.
|
|
5
5
|
License: MIT
|
|
6
6
|
Classifier: License :: OSI Approved :: MIT License
|
|
@@ -240,14 +240,40 @@ read-required:
|
|
|
240
240
|
|
|
241
241
|
Then run **`spawn refresh`** so skills and entry points pick up the hint.
|
|
242
242
|
|
|
243
|
-
|
|
243
|
+
### Supported IDEs
|
|
244
244
|
|
|
245
|
-
|
|
245
|
+
| IDE (key) | Skills | MCP | Agent ignore | Entry point | Detection markers |
|
|
246
|
+
| --- | --- | --- | --- | --- | --- |
|
|
247
|
+
| Cursor (`cursor`) | `.cursor/skills/` | `.cursor/mcp.json` | `.cursorignore` | `AGENTS.md` | `.cursor/` |
|
|
248
|
+
| Codex (`codex`) | `.agents/skills/` | `.codex/config.toml` | `config.toml` permissions | `AGENTS.md` | `.codex/`, `.agents/` |
|
|
249
|
+
| Claude Code (`claude-code`) | `.claude/skills/` | `.mcp.json` | `.claude/settings.json` | `CLAUDE.md` | `.claude/`, `CLAUDE.md` |
|
|
250
|
+
| Windsurf (`windsurf`) | `.windsurf/skills/` | — | `.codeiumignore` | `AGENTS.md` | `.windsurf/`, `.codeiumignore` |
|
|
251
|
+
| GitHub Copilot (`github-copilot`) | `.github/skills/` | `.vscode/mcp.json` | — | `.github/copilot-instructions.md`, `AGENTS.md` | `.github/`, `.vscode/` |
|
|
252
|
+
| Gemini CLI (`gemini-cli`) | `.gemini/skills/` | `.gemini/settings.json` | `.geminiignore` | `GEMINI.md` | `.gemini/`, `GEMINI.md` |
|
|
253
|
+
| Cline (`cline`) | `.cline/skills/` | — | `.clineignore` | `AGENTS.md` | `.cline/`, `.clinerules`, `.clineignore` |
|
|
254
|
+
| OpenCode (`opencode`) | `.opencode/skills/` | `opencode.json` | `opencode.json` permissions | `AGENTS.md` | `.opencode/`, `opencode.json`, `opencode.jsonc` |
|
|
255
|
+
| Mimo Code (`mimo-code`) | `.mimocode/skills/` | `.mimocode/mimocode.json` | `mimocode.json` permissions | `AGENTS.md` | `.mimocode/` |
|
|
256
|
+
| Hermes (`hermes`) | `.hermes/skills/` | — | — | `.hermes.md` | `.hermes/`, `.hermes.md`, `HERMES.md` |
|
|
257
|
+
| JetBrains Junie (`junie`) | `.junie/skills/` | `.junie/mcp/mcp.json` | `.aiignore` | `AGENTS.md` | `.junie/` |
|
|
246
258
|
|
|
247
|
-
|
|
259
|
+
**Legend:**
|
|
260
|
+
|
|
261
|
+
- **Skills** — project directory where spawn renders `SKILL.md` per skill (`native` capability; frontmatter `name` matches the directory name).
|
|
262
|
+
- **MCP** — project-scope config file where spawn merges extension MCP servers. "—" means the tool keeps MCP global-only (e.g. `~/.hermes/config.yaml`); spawn warns (`SpawnWarning`) and writes nothing.
|
|
263
|
+
- **Agent ignore** — where spawn merges agent-ignore globs: a dedicated ignore file, or permissions entries in the project config (managed block / spawn-owned entries only; removal never touches user rules). "—" means no upstream ignore mechanism; spawn warns and skips.
|
|
264
|
+
- **Entry point** — file(s) receiving the Spawn-managed instruction block (`<!-- spawn:start -->` ... `<!-- spawn:end -->`); user content around the block is preserved.
|
|
265
|
+
- **Detection markers** — files/directories that make `spawn ide list-supported-ides` (and auto-detection) mark the IDE as used in the repo.
|
|
266
|
+
|
|
267
|
+
Full per-IDE contracts (config shapes, ignore semantics, caveats): [spec/design/ide-adapters.md](spec/design/ide-adapters.md).
|
|
248
268
|
|
|
249
269
|
P.S. Below is a diagram that might help you understand how Spawn works.
|
|
250
270
|
|
|
251
271
|
<p align="center">
|
|
252
272
|
<img src="logo/Scheme.png" alt="How Spawn works" />
|
|
253
273
|
</p>
|
|
274
|
+
|
|
275
|
+
---
|
|
276
|
+
|
|
277
|
+
## License
|
|
278
|
+
|
|
279
|
+
MIT — see [LICENSE](LICENSE).
|
|
@@ -28,7 +28,11 @@ src/spawn_cli/ide/codex.py
|
|
|
28
28
|
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
|
+
src/spawn_cli/ide/hermes.py
|
|
32
|
+
src/spawn_cli/ide/junie.py
|
|
31
33
|
src/spawn_cli/ide/mcp_stdio_argv.py
|
|
34
|
+
src/spawn_cli/ide/mimo_code.py
|
|
35
|
+
src/spawn_cli/ide/opencode.py
|
|
32
36
|
src/spawn_cli/ide/registry.py
|
|
33
37
|
src/spawn_cli/ide/windsurf.py
|
|
34
38
|
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
|