spawn-cli 0.1.14__tar.gz → 0.1.16__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.14/src/spawn_cli.egg-info → spawn_cli-0.1.16}/PKG-INFO +7 -4
- {spawn_cli-0.1.14 → spawn_cli-0.1.16}/README.md +6 -3
- {spawn_cli-0.1.14 → spawn_cli-0.1.16}/pyproject.toml +1 -1
- {spawn_cli-0.1.14 → spawn_cli-0.1.16}/src/spawn_cli/cli.py +42 -0
- {spawn_cli-0.1.14 → spawn_cli-0.1.16}/src/spawn_cli/core/high_level.py +9 -0
- {spawn_cli-0.1.14 → spawn_cli-0.1.16}/src/spawn_cli/core/low_level.py +95 -6
- spawn_cli-0.1.16/src/spawn_cli/core/mcp_stdio.py +67 -0
- {spawn_cli-0.1.14 → spawn_cli-0.1.16}/src/spawn_cli/ide/__init__.py +2 -0
- {spawn_cli-0.1.14 → spawn_cli-0.1.16}/src/spawn_cli/ide/claude_code.py +5 -1
- {spawn_cli-0.1.14 → spawn_cli-0.1.16}/src/spawn_cli/ide/codex.py +7 -2
- {spawn_cli-0.1.14 → spawn_cli-0.1.16}/src/spawn_cli/ide/cursor.py +5 -1
- {spawn_cli-0.1.14 → spawn_cli-0.1.16}/src/spawn_cli/ide/gemini_cli.py +7 -2
- {spawn_cli-0.1.14 → spawn_cli-0.1.16}/src/spawn_cli/ide/github_copilot.py +7 -2
- spawn_cli-0.1.16/src/spawn_cli/ide/mcp_stdio_argv.py +10 -0
- {spawn_cli-0.1.14 → spawn_cli-0.1.16}/src/spawn_cli/models/__init__.py +2 -0
- {spawn_cli-0.1.14 → spawn_cli-0.1.16}/src/spawn_cli/models/mcp.py +2 -1
- spawn_cli-0.1.16/src/spawn_cli/models/navigation.py +52 -0
- {spawn_cli-0.1.14 → spawn_cli-0.1.16/src/spawn_cli.egg-info}/PKG-INFO +7 -4
- {spawn_cli-0.1.14 → spawn_cli-0.1.16}/src/spawn_cli.egg-info/SOURCES.txt +2 -0
- {spawn_cli-0.1.14 → spawn_cli-0.1.16}/tests/test_cli.py +64 -0
- spawn_cli-0.1.14/src/spawn_cli/models/navigation.py +0 -30
- {spawn_cli-0.1.14 → spawn_cli-0.1.16}/LICENSE +0 -0
- {spawn_cli-0.1.14 → spawn_cli-0.1.16}/setup.cfg +0 -0
- {spawn_cli-0.1.14 → spawn_cli-0.1.16}/src/spawn_cli/__init__.py +0 -0
- {spawn_cli-0.1.14 → spawn_cli-0.1.16}/src/spawn_cli/core/__init__.py +0 -0
- {spawn_cli-0.1.14 → spawn_cli-0.1.16}/src/spawn_cli/core/download.py +0 -0
- {spawn_cli-0.1.14 → spawn_cli-0.1.16}/src/spawn_cli/core/errors.py +0 -0
- {spawn_cli-0.1.14 → spawn_cli-0.1.16}/src/spawn_cli/core/scripts.py +0 -0
- {spawn_cli-0.1.14 → spawn_cli-0.1.16}/src/spawn_cli/errors.py +0 -0
- {spawn_cli-0.1.14 → spawn_cli-0.1.16}/src/spawn_cli/ide/_helpers.py +0 -0
- {spawn_cli-0.1.14 → spawn_cli-0.1.16}/src/spawn_cli/ide/_stub.py +0 -0
- {spawn_cli-0.1.14 → spawn_cli-0.1.16}/src/spawn_cli/ide/_vacancy.py +0 -0
- {spawn_cli-0.1.14 → spawn_cli-0.1.16}/src/spawn_cli/ide/registry.py +0 -0
- {spawn_cli-0.1.14 → spawn_cli-0.1.16}/src/spawn_cli/ide/windsurf.py +0 -0
- {spawn_cli-0.1.14 → spawn_cli-0.1.16}/src/spawn_cli/io/__init__.py +0 -0
- {spawn_cli-0.1.14 → spawn_cli-0.1.16}/src/spawn_cli/io/json_io.py +0 -0
- {spawn_cli-0.1.14 → spawn_cli-0.1.16}/src/spawn_cli/io/lock.py +0 -0
- {spawn_cli-0.1.14 → spawn_cli-0.1.16}/src/spawn_cli/io/paths.py +0 -0
- {spawn_cli-0.1.14 → spawn_cli-0.1.16}/src/spawn_cli/io/text_io.py +0 -0
- {spawn_cli-0.1.14 → spawn_cli-0.1.16}/src/spawn_cli/io/toml_io.py +0 -0
- {spawn_cli-0.1.14 → spawn_cli-0.1.16}/src/spawn_cli/io/yaml_io.py +0 -0
- {spawn_cli-0.1.14 → spawn_cli-0.1.16}/src/spawn_cli/models/config.py +0 -0
- {spawn_cli-0.1.14 → spawn_cli-0.1.16}/src/spawn_cli/models/metadata.py +0 -0
- {spawn_cli-0.1.14 → spawn_cli-0.1.16}/src/spawn_cli/models/skill.py +0 -0
- {spawn_cli-0.1.14 → spawn_cli-0.1.16}/src/spawn_cli/resources/__init__.py +0 -0
- {spawn_cli-0.1.14 → spawn_cli-0.1.16}/src/spawn_cli/resources/default_core_config.yaml +0 -0
- {spawn_cli-0.1.14 → spawn_cli-0.1.16}/src/spawn_cli/warnings_display.py +0 -0
- {spawn_cli-0.1.14 → spawn_cli-0.1.16}/src/spawn_cli.egg-info/dependency_links.txt +0 -0
- {spawn_cli-0.1.14 → spawn_cli-0.1.16}/src/spawn_cli.egg-info/entry_points.txt +0 -0
- {spawn_cli-0.1.14 → spawn_cli-0.1.16}/src/spawn_cli.egg-info/requires.txt +0 -0
- {spawn_cli-0.1.14 → spawn_cli-0.1.16}/src/spawn_cli.egg-info/top_level.txt +0 -0
- {spawn_cli-0.1.14 → spawn_cli-0.1.16}/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.16
|
|
4
4
|
Summary: A minimal installable Python CLI package.
|
|
5
5
|
License: MIT
|
|
6
6
|
Classifier: License :: OSI Approved :: MIT License
|
|
@@ -217,9 +217,11 @@ That rescans **`spawn/rules/`**, attaches missing rule files under a **`rules`**
|
|
|
217
217
|
|
|
218
218
|
### 2. Hints in `spawn/navigation.yaml`
|
|
219
219
|
|
|
220
|
-
For **short, always-visible reminders** (tone, language, repo-specific habits), add
|
|
220
|
+
For **short, always-visible reminders** (tone, language, repo-specific habits), add **hint-only** rows under **`read-required` → `rules`** (a **`hint`** entry **without** a **`path`**). Mandatory rule files use rows with **`path`** and **`description`** only—do **not** add **`hint`** on the same row as **`path`**; if refresh finds legacy pairs, it splits them into a file row and a following hint-only row.
|
|
221
221
|
|
|
222
|
-
|
|
222
|
+
Hints from **`read-required` → `rules`** (hint-only rows) appear in skills’ **Hints** section and in the managed **`AGENTS.md`** rollup together with extension **`hints.global`**, using YAML row order within maintainer hints before dedupe (**`hint`** on **`read-contextual` → `rules`** is still **not** included there).
|
|
223
|
+
|
|
224
|
+
If a **`path`** names a **`spawn/rules/…`** file that **does not** exist when navigation is refreshed, that row is **removed entirely** — fix or drop **`path`** first; **`hint`** on that same removed row is **not** downgraded to a hint-only row.
|
|
223
225
|
|
|
224
226
|
Example:
|
|
225
227
|
|
|
@@ -228,7 +230,8 @@ read-required:
|
|
|
228
230
|
- rules:
|
|
229
231
|
- path: spawn/rules/team.md
|
|
230
232
|
description: Team conventions.
|
|
231
|
-
|
|
233
|
+
- hint: Prefer British spelling in user-facing copy.
|
|
234
|
+
- hint: Short standalone reminder with no rule file.
|
|
232
235
|
```
|
|
233
236
|
|
|
234
237
|
Then run **`spawn refresh`** so skills and entry points pick up the hint.
|
|
@@ -200,9 +200,11 @@ That rescans **`spawn/rules/`**, attaches missing rule files under a **`rules`**
|
|
|
200
200
|
|
|
201
201
|
### 2. Hints in `spawn/navigation.yaml`
|
|
202
202
|
|
|
203
|
-
For **short, always-visible reminders** (tone, language, repo-specific habits), add
|
|
203
|
+
For **short, always-visible reminders** (tone, language, repo-specific habits), add **hint-only** rows under **`read-required` → `rules`** (a **`hint`** entry **without** a **`path`**). Mandatory rule files use rows with **`path`** and **`description`** only—do **not** add **`hint`** on the same row as **`path`**; if refresh finds legacy pairs, it splits them into a file row and a following hint-only row.
|
|
204
204
|
|
|
205
|
-
|
|
205
|
+
Hints from **`read-required` → `rules`** (hint-only rows) appear in skills’ **Hints** section and in the managed **`AGENTS.md`** rollup together with extension **`hints.global`**, using YAML row order within maintainer hints before dedupe (**`hint`** on **`read-contextual` → `rules`** is still **not** included there).
|
|
206
|
+
|
|
207
|
+
If a **`path`** names a **`spawn/rules/…`** file that **does not** exist when navigation is refreshed, that row is **removed entirely** — fix or drop **`path`** first; **`hint`** on that same removed row is **not** downgraded to a hint-only row.
|
|
206
208
|
|
|
207
209
|
Example:
|
|
208
210
|
|
|
@@ -211,7 +213,8 @@ read-required:
|
|
|
211
213
|
- rules:
|
|
212
214
|
- path: spawn/rules/team.md
|
|
213
215
|
description: Team conventions.
|
|
214
|
-
|
|
216
|
+
- hint: Prefer British spelling in user-facing copy.
|
|
217
|
+
- hint: Short standalone reminder with no rule file.
|
|
215
218
|
```
|
|
216
219
|
|
|
217
220
|
Then run **`spawn refresh`** so skills and entry points pick up the hint.
|
|
@@ -8,6 +8,7 @@ from spawn_cli import __version__
|
|
|
8
8
|
from spawn_cli.core import download as dl
|
|
9
9
|
from spawn_cli.core import high_level as hl
|
|
10
10
|
from spawn_cli.core import low_level as ll
|
|
11
|
+
from spawn_cli.core import mcp_stdio
|
|
11
12
|
from spawn_cli.core.errors import SpawnError
|
|
12
13
|
from spawn_cli.ide.registry import DetectResult, detect_supported_ides
|
|
13
14
|
from spawn_cli.io.lock import spawn_lock
|
|
@@ -298,6 +299,38 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
298
299
|
help="Fallback Git revision applied when entries lack branch.",
|
|
299
300
|
)
|
|
300
301
|
|
|
302
|
+
mcp_stdio_p = sub.add_parser(
|
|
303
|
+
"mcp_stdio",
|
|
304
|
+
help="Run stdio MCP for an extension server (IDE-stable wrapper)",
|
|
305
|
+
description=(
|
|
306
|
+
"Resolve the inner stdio command from installed extension MCP JSON for this OS "
|
|
307
|
+
"and relay MCP traffic on stdin/stdout. Intended as the IDE MCP entrypoint when "
|
|
308
|
+
"spawn_stdio_proxy is enabled; does not acquire the repository lock."
|
|
309
|
+
),
|
|
310
|
+
)
|
|
311
|
+
mcp_stdio_l1 = mcp_stdio_p.add_subparsers(dest="mcp_stdio_l1", required=True)
|
|
312
|
+
mcp_stdio_ext = mcp_stdio_l1.add_parser(
|
|
313
|
+
"extension",
|
|
314
|
+
help="Pick installed extension id",
|
|
315
|
+
description="Extension directory name under spawn/.extend/ (same ids as spawn extension list).",
|
|
316
|
+
)
|
|
317
|
+
mcp_stdio_ext.add_argument(
|
|
318
|
+
"mcp_stdio_extension_id",
|
|
319
|
+
metavar="EXTENSION_ID",
|
|
320
|
+
help="Installed extension id (folder under spawn/.extend/).",
|
|
321
|
+
)
|
|
322
|
+
mcp_stdio_l2 = mcp_stdio_ext.add_subparsers(dest="mcp_stdio_l2", required=True)
|
|
323
|
+
mcp_stdio_name = mcp_stdio_l2.add_parser(
|
|
324
|
+
"name",
|
|
325
|
+
help="Pick MCP server by declared name",
|
|
326
|
+
description="Must match servers[].name in mcp/<platform>.json for the current OS.",
|
|
327
|
+
)
|
|
328
|
+
mcp_stdio_name.add_argument(
|
|
329
|
+
"mcp_stdio_server_name",
|
|
330
|
+
metavar="SERVER_NAME",
|
|
331
|
+
help="MCP server name from the extension platform MCP file.",
|
|
332
|
+
)
|
|
333
|
+
|
|
301
334
|
return parser
|
|
302
335
|
|
|
303
336
|
|
|
@@ -327,6 +360,9 @@ def _dispatch(args: argparse.Namespace, target_root: Path) -> int:
|
|
|
327
360
|
|
|
328
361
|
_require_init(target_root)
|
|
329
362
|
|
|
363
|
+
if cmd == "mcp_stdio":
|
|
364
|
+
return _dispatch_mcp_stdio(args, target_root)
|
|
365
|
+
|
|
330
366
|
with spawn_lock(target_root):
|
|
331
367
|
if cmd == "rules":
|
|
332
368
|
return _dispatch_rules(args, target_root)
|
|
@@ -343,6 +379,12 @@ def _dispatch(args: argparse.Namespace, target_root: Path) -> int:
|
|
|
343
379
|
return 1
|
|
344
380
|
|
|
345
381
|
|
|
382
|
+
def _dispatch_mcp_stdio(args: argparse.Namespace, target_root: Path) -> int:
|
|
383
|
+
return mcp_stdio.run_mcp_stdio_proxy(
|
|
384
|
+
target_root, args.mcp_stdio_extension_id, args.mcp_stdio_server_name
|
|
385
|
+
)
|
|
386
|
+
|
|
387
|
+
|
|
346
388
|
def _dispatch_rules(args: argparse.Namespace, target_root: Path) -> int:
|
|
347
389
|
sub = args.rules_command
|
|
348
390
|
if sub == "refresh":
|
|
@@ -520,6 +520,15 @@ def extension_check(path: Path, strict: bool = False) -> list[str]:
|
|
|
520
520
|
warnings_out.append(f"MCP file invalid ({p}): {e}")
|
|
521
521
|
name_sets = []
|
|
522
522
|
break
|
|
523
|
+
for srv in nm.servers:
|
|
524
|
+
if srv.spawn_stdio_proxy and srv.transport.type != "stdio":
|
|
525
|
+
msg = (
|
|
526
|
+
f"MCP server {srv.name!r} in {p}: spawn_stdio_proxy requires "
|
|
527
|
+
f"transport.type 'stdio' (got {srv.transport.type!r})"
|
|
528
|
+
)
|
|
529
|
+
if strict:
|
|
530
|
+
raise SpawnError(msg)
|
|
531
|
+
warnings_out.append(msg)
|
|
523
532
|
name_sets.append(frozenset(s.name for s in nm.servers))
|
|
524
533
|
if name_sets and len(set(name_sets)) != 1:
|
|
525
534
|
msg = (
|
|
@@ -194,9 +194,12 @@ def _merge_hint_streams_ordered(streams: list[list[str]]) -> list[str]:
|
|
|
194
194
|
|
|
195
195
|
|
|
196
196
|
def _navigation_read_required_rule_hints_ordered(target_root: Path) -> list[str]:
|
|
197
|
-
"""Plain-text hints from maintainer-authored read-required ``rules`` rows
|
|
197
|
+
"""Plain-text hints from maintainer-authored ``read-required`` ``rules`` rows (YAML order).
|
|
198
198
|
|
|
199
|
+
Only **hint-only** rows (no non-empty stripped ``path``) contribute; ``hint`` on file-backed
|
|
200
|
+
rows is ignored here (and dropped on navigation refresh).
|
|
199
201
|
Hint fields on ``read-contextual`` rules are ignored here.
|
|
202
|
+
Strips each hint; whitespace-only hints are omitted.
|
|
200
203
|
"""
|
|
201
204
|
path = _spawn(target_root) / "navigation.yaml"
|
|
202
205
|
raw = load_yaml(path)
|
|
@@ -215,9 +218,14 @@ def _navigation_read_required_rule_hints_ordered(target_root: Path) -> list[str]
|
|
|
215
218
|
for entry in rules_raw:
|
|
216
219
|
if not isinstance(entry, dict):
|
|
217
220
|
continue
|
|
221
|
+
path_val = entry.get("path")
|
|
222
|
+
if isinstance(path_val, str) and path_val.strip():
|
|
223
|
+
continue
|
|
218
224
|
hint_val = entry.get("hint")
|
|
219
225
|
if isinstance(hint_val, str):
|
|
220
|
-
|
|
226
|
+
stripped = hint_val.strip()
|
|
227
|
+
if stripped:
|
|
228
|
+
out.append(stripped)
|
|
221
229
|
return out
|
|
222
230
|
|
|
223
231
|
|
|
@@ -656,6 +664,16 @@ def _parse_mcp_env(env_raw: dict[str, Any]) -> dict[str, McpEnvVar]:
|
|
|
656
664
|
return out
|
|
657
665
|
|
|
658
666
|
|
|
667
|
+
def merged_os_environ_with_mcp_env(
|
|
668
|
+
base: dict[str, str], mcp_env: dict[str, McpEnvVar]
|
|
669
|
+
) -> dict[str, str]:
|
|
670
|
+
out = dict(base)
|
|
671
|
+
for key, spec in mcp_env.items():
|
|
672
|
+
if spec.value is not None:
|
|
673
|
+
out[key] = spec.value
|
|
674
|
+
return out
|
|
675
|
+
|
|
676
|
+
|
|
659
677
|
def _mcp_capabilities(raw: dict[str, Any]) -> McpCapabilities:
|
|
660
678
|
c = raw.get("capabilities") or {}
|
|
661
679
|
return McpCapabilities.model_validate(c) if isinstance(c, dict) else McpCapabilities()
|
|
@@ -679,6 +697,17 @@ def extension_mcp_platform_json_paths(extension_root: Path) -> tuple[Path, Path,
|
|
|
679
697
|
return tuple(d / f"{s}.json" for s in MCP_PLATFORM_STEMS)
|
|
680
698
|
|
|
681
699
|
|
|
700
|
+
def _mcp_spawn_stdio_proxy_from_server(server: dict, path: Path, server_name: str) -> bool:
|
|
701
|
+
if "spawn_stdio_proxy" not in server:
|
|
702
|
+
return False
|
|
703
|
+
v = server["spawn_stdio_proxy"]
|
|
704
|
+
if isinstance(v, bool):
|
|
705
|
+
return v
|
|
706
|
+
raise SpawnError(
|
|
707
|
+
f"spawn_stdio_proxy must be a boolean for server {server_name!r} in {path}"
|
|
708
|
+
)
|
|
709
|
+
|
|
710
|
+
|
|
682
711
|
def _normalized_mcp_from_loaded(data: dict, path: Path, extension: str) -> NormalizedMcp:
|
|
683
712
|
servers_out: list[McpServer] = []
|
|
684
713
|
for s in data.get("servers") or []:
|
|
@@ -687,6 +716,8 @@ def _normalized_mcp_from_loaded(data: dict, path: Path, extension: str) -> Norma
|
|
|
687
716
|
nm = s.get("name")
|
|
688
717
|
if nm is None:
|
|
689
718
|
raise SpawnError(f"missing MCP server name in {path}")
|
|
719
|
+
str_name = str(nm)
|
|
720
|
+
spawn_proxy = _mcp_spawn_stdio_proxy_from_server(s, path, str_name)
|
|
690
721
|
tr_raw = s.get("transport") or {}
|
|
691
722
|
transport = McpTransport(
|
|
692
723
|
type=str(tr_raw.get("type", "stdio")),
|
|
@@ -699,9 +730,10 @@ def _normalized_mcp_from_loaded(data: dict, path: Path, extension: str) -> Norma
|
|
|
699
730
|
env = _parse_mcp_env(env_in) if isinstance(env_in, dict) else {}
|
|
700
731
|
servers_out.append(
|
|
701
732
|
McpServer(
|
|
702
|
-
name=
|
|
733
|
+
name=str_name,
|
|
703
734
|
extension=extension,
|
|
704
735
|
transport=transport,
|
|
736
|
+
spawn_stdio_proxy=spawn_proxy,
|
|
705
737
|
env=env,
|
|
706
738
|
capabilities=_mcp_capabilities(s),
|
|
707
739
|
)
|
|
@@ -1178,7 +1210,64 @@ def save_rules_navigation(target_root: Path) -> None:
|
|
|
1178
1210
|
def posix_norm(p: str) -> str:
|
|
1179
1211
|
return Path(p).as_posix().replace('\\', '/')
|
|
1180
1212
|
|
|
1181
|
-
def
|
|
1213
|
+
def prune_read_required_rules(lst: list[Any]) -> None:
|
|
1214
|
+
"""Keep file-backed rows with existing paths (no ``hint`` on those rows); keep hint-only rows."""
|
|
1215
|
+
|
|
1216
|
+
out: list[Any] = []
|
|
1217
|
+
for entry in lst:
|
|
1218
|
+
if not isinstance(entry, dict):
|
|
1219
|
+
continue
|
|
1220
|
+
hint_raw = entry.get('hint')
|
|
1221
|
+
hint_is_str = isinstance(hint_raw, str)
|
|
1222
|
+
hint_stripped = hint_raw.strip() if hint_is_str else ''
|
|
1223
|
+
path_in_entry = 'path' in entry
|
|
1224
|
+
path_raw = entry.get('path')
|
|
1225
|
+
|
|
1226
|
+
if path_in_entry:
|
|
1227
|
+
if isinstance(path_raw, str) and path_raw.strip():
|
|
1228
|
+
rk = posix_norm(path_raw.strip())
|
|
1229
|
+
exists = rk in paths_on_disk and (Path(target_root) / Path(rk)).is_file()
|
|
1230
|
+
if exists:
|
|
1231
|
+
row: dict[str, Any] = {
|
|
1232
|
+
'path': rk,
|
|
1233
|
+
'description': str(entry.get('description') or 'Local rule file.'),
|
|
1234
|
+
}
|
|
1235
|
+
out.append(row)
|
|
1236
|
+
if hint_stripped:
|
|
1237
|
+
warnings.warn(
|
|
1238
|
+
f'Moved maintainer hint from file-backed rule row to standalone '
|
|
1239
|
+
f'hint row: {rk}',
|
|
1240
|
+
SpawnWarning,
|
|
1241
|
+
)
|
|
1242
|
+
out.append({'hint': hint_stripped})
|
|
1243
|
+
else:
|
|
1244
|
+
warnings.warn(
|
|
1245
|
+
f"Removed missing rule path from navigation: {rk}",
|
|
1246
|
+
SpawnWarning,
|
|
1247
|
+
)
|
|
1248
|
+
else:
|
|
1249
|
+
warnings.warn(
|
|
1250
|
+
'Removed invalid rule row from navigation (empty path field).',
|
|
1251
|
+
SpawnWarning,
|
|
1252
|
+
)
|
|
1253
|
+
continue
|
|
1254
|
+
|
|
1255
|
+
if hint_stripped:
|
|
1256
|
+
row_h: dict[str, Any] = {'hint': hint_stripped}
|
|
1257
|
+
desc_raw = entry.get('description')
|
|
1258
|
+
if isinstance(desc_raw, str) and desc_raw.strip():
|
|
1259
|
+
row_h['description'] = desc_raw.strip()
|
|
1260
|
+
out.append(row_h)
|
|
1261
|
+
continue
|
|
1262
|
+
|
|
1263
|
+
warnings.warn(
|
|
1264
|
+
'Removed invalid rule row from navigation (no path and no hint).',
|
|
1265
|
+
SpawnWarning,
|
|
1266
|
+
)
|
|
1267
|
+
|
|
1268
|
+
lst[:] = out
|
|
1269
|
+
|
|
1270
|
+
def prune_contextual_rules(lst: list[Any]) -> None:
|
|
1182
1271
|
out: list[Any] = []
|
|
1183
1272
|
for entry in lst:
|
|
1184
1273
|
if not isinstance(entry, dict) or 'path' not in entry:
|
|
@@ -1201,9 +1290,9 @@ def save_rules_navigation(target_root: Path) -> None:
|
|
|
1201
1290
|
)
|
|
1202
1291
|
lst[:] = out
|
|
1203
1292
|
|
|
1204
|
-
|
|
1293
|
+
prune_read_required_rules(rq_rules_list)
|
|
1205
1294
|
if cq_rules_list is not None:
|
|
1206
|
-
|
|
1295
|
+
prune_contextual_rules(cq_rules_list)
|
|
1207
1296
|
|
|
1208
1297
|
raw["read-required"] = rr
|
|
1209
1298
|
raw["read-contextual"] = rc
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Runtime stdio MCP proxy: resolve server from platform JSON and exec inner command."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from spawn_cli.core.errors import SpawnError
|
|
11
|
+
from spawn_cli.core.low_level import list_extensions, list_mcp, merged_os_environ_with_mcp_env
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def run_mcp_stdio_proxy(target_root: Path, extension_id: str, server_name: str) -> int:
|
|
15
|
+
if extension_id not in list_extensions(target_root):
|
|
16
|
+
raise SpawnError(
|
|
17
|
+
f"unknown extension {extension_id!r}: install it under spawn/.extend/<id>/ "
|
|
18
|
+
f"(see: spawn extension list)."
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
nm = list_mcp(target_root, extension_id)
|
|
22
|
+
srv = next((s for s in nm.servers if s.name == server_name), None)
|
|
23
|
+
existing = [s.name for s in nm.servers]
|
|
24
|
+
if srv is None:
|
|
25
|
+
raise SpawnError(
|
|
26
|
+
f"unknown MCP server {server_name!r} for extension {extension_id!r}; "
|
|
27
|
+
f"declared servers: {existing}"
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
if not srv.spawn_stdio_proxy:
|
|
31
|
+
raise SpawnError(
|
|
32
|
+
f"MCP server {server_name!r} in extension {extension_id!r} is not enabled for "
|
|
33
|
+
f"stdio proxy: set \"spawn_stdio_proxy\": true on that server in "
|
|
34
|
+
f"extsrc/mcp/*.json (then reinstall or refresh)."
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
if srv.transport.type != "stdio":
|
|
38
|
+
raise SpawnError(
|
|
39
|
+
f"MCP server {server_name!r} uses transport type {srv.transport.type!r}; "
|
|
40
|
+
f"spawn mcp_stdio requires type \"stdio\"."
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
cmd = srv.transport.command
|
|
44
|
+
if not cmd:
|
|
45
|
+
raise SpawnError(
|
|
46
|
+
f"MCP server {server_name!r} has no transport.command; "
|
|
47
|
+
f"fix stdio transport in extension MCP JSON."
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
argv = [cmd, *srv.transport.args]
|
|
51
|
+
cwd_path = (target_root / srv.transport.cwd).resolve()
|
|
52
|
+
child_env = merged_os_environ_with_mcp_env(os.environ, srv.env)
|
|
53
|
+
|
|
54
|
+
try:
|
|
55
|
+
with subprocess.Popen(
|
|
56
|
+
argv,
|
|
57
|
+
cwd=str(cwd_path),
|
|
58
|
+
env=child_env,
|
|
59
|
+
stdin=sys.stdin.buffer,
|
|
60
|
+
stdout=sys.stdout.buffer,
|
|
61
|
+
stderr=None,
|
|
62
|
+
shell=False,
|
|
63
|
+
bufsize=0,
|
|
64
|
+
) as proc:
|
|
65
|
+
return int(proc.wait())
|
|
66
|
+
except OSError as e:
|
|
67
|
+
raise SpawnError(f"failed to start MCP server {server_name!r}: {e}") from e
|
|
@@ -14,6 +14,7 @@ from .codex import CodexAdapter
|
|
|
14
14
|
from .cursor import CursorAdapter
|
|
15
15
|
from .gemini_cli import GeminiCliAdapter
|
|
16
16
|
from .github_copilot import GitHubCopilotAdapter
|
|
17
|
+
from .mcp_stdio_argv import mcp_stdio_argv
|
|
17
18
|
from .windsurf import WindsurfAdapter
|
|
18
19
|
from .registry import (
|
|
19
20
|
ALIASES,
|
|
@@ -55,6 +56,7 @@ __all__ = [
|
|
|
55
56
|
"IGNORE_BLOCK_START",
|
|
56
57
|
"IdeAdapter",
|
|
57
58
|
"IdeCapabilities",
|
|
59
|
+
"mcp_stdio_argv",
|
|
58
60
|
"SPAWN_BLOCK_END",
|
|
59
61
|
"SPAWN_BLOCK_START",
|
|
60
62
|
"StubAdapter",
|
|
@@ -8,6 +8,7 @@ from pathlib import Path
|
|
|
8
8
|
|
|
9
9
|
from spawn_cli.core import low_level as ll
|
|
10
10
|
from spawn_cli.ide import _vacancy as _vac
|
|
11
|
+
from spawn_cli.ide.mcp_stdio_argv import mcp_stdio_argv
|
|
11
12
|
from spawn_cli.ide.registry import (
|
|
12
13
|
DetectResult,
|
|
13
14
|
IdeAdapter,
|
|
@@ -24,7 +25,10 @@ from spawn_cli.models.skill import SkillMetadata
|
|
|
24
25
|
def _build_generic_mcp_entry(server: McpServer) -> dict:
|
|
25
26
|
transport = server.transport
|
|
26
27
|
if transport.type == "stdio":
|
|
27
|
-
|
|
28
|
+
if server.spawn_stdio_proxy:
|
|
29
|
+
entry = {"command": "spawn", "args": mcp_stdio_argv(server.extension, server.name)}
|
|
30
|
+
else:
|
|
31
|
+
entry = {"command": transport.command, "args": list(transport.args)}
|
|
28
32
|
elif transport.type in ("streamable-http", "sse"):
|
|
29
33
|
entry = {"type": transport.type, "url": transport.url}
|
|
30
34
|
if transport.headers:
|
|
@@ -6,6 +6,7 @@ import warnings
|
|
|
6
6
|
from pathlib import Path
|
|
7
7
|
|
|
8
8
|
from spawn_cli.ide import _vacancy as _vac
|
|
9
|
+
from spawn_cli.ide.mcp_stdio_argv import mcp_stdio_argv
|
|
9
10
|
from spawn_cli.ide.registry import (
|
|
10
11
|
DetectResult,
|
|
11
12
|
IdeAdapter,
|
|
@@ -24,8 +25,12 @@ def _build_toml_server_entry(server: McpServer) -> dict:
|
|
|
24
25
|
transport = server.transport
|
|
25
26
|
entry: dict = {}
|
|
26
27
|
if transport.type == "stdio":
|
|
27
|
-
|
|
28
|
-
|
|
28
|
+
if server.spawn_stdio_proxy:
|
|
29
|
+
entry["command"] = "spawn"
|
|
30
|
+
entry["args"] = mcp_stdio_argv(server.extension, server.name)
|
|
31
|
+
else:
|
|
32
|
+
entry["command"] = transport.command
|
|
33
|
+
entry["args"] = list(transport.args)
|
|
29
34
|
else:
|
|
30
35
|
entry["url"] = transport.url
|
|
31
36
|
if server.env:
|
|
@@ -7,6 +7,7 @@ import warnings
|
|
|
7
7
|
from pathlib import Path
|
|
8
8
|
|
|
9
9
|
from spawn_cli.ide import _vacancy as _vac
|
|
10
|
+
from spawn_cli.ide.mcp_stdio_argv import mcp_stdio_argv
|
|
10
11
|
from spawn_cli.ide._helpers import (
|
|
11
12
|
clear_split_agent_ignore_file,
|
|
12
13
|
remove_ignore_block,
|
|
@@ -31,7 +32,10 @@ def _build_mcp_server_entry(server: McpServer) -> dict:
|
|
|
31
32
|
"""Convert McpServer to .cursor/mcp.json entry (mcpServers format)."""
|
|
32
33
|
transport = server.transport
|
|
33
34
|
if transport.type == "stdio":
|
|
34
|
-
|
|
35
|
+
if server.spawn_stdio_proxy:
|
|
36
|
+
entry = {"command": "spawn", "args": mcp_stdio_argv(server.extension, server.name)}
|
|
37
|
+
else:
|
|
38
|
+
entry = {"command": transport.command, "args": transport.args}
|
|
35
39
|
elif transport.type in ("streamable-http", "sse"):
|
|
36
40
|
entry = {"type": transport.type, "url": transport.url}
|
|
37
41
|
if transport.headers:
|
|
@@ -7,6 +7,7 @@ import warnings
|
|
|
7
7
|
from pathlib import Path
|
|
8
8
|
|
|
9
9
|
from spawn_cli.ide import _vacancy as _vac
|
|
10
|
+
from spawn_cli.ide.mcp_stdio_argv import mcp_stdio_argv
|
|
10
11
|
from spawn_cli.ide._helpers import (
|
|
11
12
|
clear_split_agent_ignore_file,
|
|
12
13
|
remove_ignore_block,
|
|
@@ -31,8 +32,12 @@ def _build_gemini_mcp_entry(server: McpServer) -> dict:
|
|
|
31
32
|
transport = server.transport
|
|
32
33
|
entry: dict = {}
|
|
33
34
|
if transport.type == "stdio":
|
|
34
|
-
|
|
35
|
-
|
|
35
|
+
if server.spawn_stdio_proxy:
|
|
36
|
+
entry["command"] = "spawn"
|
|
37
|
+
entry["args"] = mcp_stdio_argv(server.extension, server.name)
|
|
38
|
+
else:
|
|
39
|
+
entry["command"] = transport.command
|
|
40
|
+
entry["args"] = list(transport.args)
|
|
36
41
|
elif transport.type == "streamable-http":
|
|
37
42
|
entry["httpUrl"] = transport.url or ""
|
|
38
43
|
if transport.headers:
|
|
@@ -7,6 +7,7 @@ import warnings
|
|
|
7
7
|
from pathlib import Path
|
|
8
8
|
|
|
9
9
|
from spawn_cli.ide import _vacancy as _vac
|
|
10
|
+
from spawn_cli.ide.mcp_stdio_argv import mcp_stdio_argv
|
|
10
11
|
from spawn_cli.ide.registry import (
|
|
11
12
|
DetectResult,
|
|
12
13
|
IdeAdapter,
|
|
@@ -31,8 +32,12 @@ def _build_vscode_mcp_entry(server: McpServer) -> tuple[dict, list[dict]]:
|
|
|
31
32
|
|
|
32
33
|
if transport.type == "stdio":
|
|
33
34
|
entry["type"] = "stdio"
|
|
34
|
-
|
|
35
|
-
|
|
35
|
+
if server.spawn_stdio_proxy:
|
|
36
|
+
entry["command"] = "spawn"
|
|
37
|
+
entry["args"] = mcp_stdio_argv(server.extension, server.name)
|
|
38
|
+
else:
|
|
39
|
+
entry["command"] = transport.command
|
|
40
|
+
entry["args"] = list(transport.args)
|
|
36
41
|
else:
|
|
37
42
|
entry["type"] = transport.type
|
|
38
43
|
entry["url"] = transport.url
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""Shared argv list for IDE MCP configs that launch ``spawn mcp_stdio``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def mcp_stdio_argv(extension_id: str, server_name: str) -> list[str]:
|
|
7
|
+
return ["mcp_stdio", "extension", extension_id, "name", server_name]
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
__all__ = ["mcp_stdio_argv"]
|
|
@@ -27,6 +27,7 @@ from spawn_cli.models.metadata import (
|
|
|
27
27
|
from spawn_cli.models.navigation import (
|
|
28
28
|
NavExtGroup,
|
|
29
29
|
NavFile,
|
|
30
|
+
NavRuleRow,
|
|
30
31
|
NavRulesGroup,
|
|
31
32
|
NavigationFile,
|
|
32
33
|
)
|
|
@@ -55,6 +56,7 @@ __all__ = [
|
|
|
55
56
|
"RenderedSkillsMeta",
|
|
56
57
|
"NavExtGroup",
|
|
57
58
|
"NavFile",
|
|
59
|
+
"NavRuleRow",
|
|
58
60
|
"NavRulesGroup",
|
|
59
61
|
"NavigationFile",
|
|
60
62
|
"SkillFileRef",
|
|
@@ -2,7 +2,7 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
from typing import Optional
|
|
4
4
|
|
|
5
|
-
from pydantic import BaseModel, ConfigDict, Field
|
|
5
|
+
from pydantic import BaseModel, ConfigDict, Field, StrictBool
|
|
6
6
|
|
|
7
7
|
|
|
8
8
|
class McpEnvVar(BaseModel):
|
|
@@ -39,6 +39,7 @@ class McpServer(BaseModel):
|
|
|
39
39
|
name: str
|
|
40
40
|
extension: str
|
|
41
41
|
transport: McpTransport
|
|
42
|
+
spawn_stdio_proxy: StrictBool = False
|
|
42
43
|
env: dict[str, McpEnvVar] = Field(default_factory=dict)
|
|
43
44
|
capabilities: McpCapabilities = Field(default_factory=McpCapabilities)
|
|
44
45
|
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class NavFile(BaseModel):
|
|
7
|
+
model_config = ConfigDict(populate_by_name=True)
|
|
8
|
+
|
|
9
|
+
path: str
|
|
10
|
+
description: str
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class NavExtGroup(BaseModel):
|
|
14
|
+
model_config = ConfigDict(populate_by_name=True)
|
|
15
|
+
|
|
16
|
+
ext: str
|
|
17
|
+
files: list[NavFile] = Field(default_factory=list)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class NavRuleRow(BaseModel):
|
|
21
|
+
"""Maintainer ``read-required`` ``rules`` row: file-backed (``path``) or hint-only (``hint``), never both."""
|
|
22
|
+
|
|
23
|
+
model_config = ConfigDict(populate_by_name=True)
|
|
24
|
+
|
|
25
|
+
path: str | None = None
|
|
26
|
+
description: str | None = None
|
|
27
|
+
hint: str | None = None
|
|
28
|
+
|
|
29
|
+
@model_validator(mode='after')
|
|
30
|
+
def path_xor_hint(self) -> NavRuleRow:
|
|
31
|
+
path_ok = bool(self.path and str(self.path).strip())
|
|
32
|
+
hint_ok = bool(self.hint and str(self.hint).strip())
|
|
33
|
+
if not path_ok and not hint_ok:
|
|
34
|
+
raise ValueError('navigation rule row needs a non-empty path or a non-empty hint')
|
|
35
|
+
if path_ok and hint_ok:
|
|
36
|
+
raise ValueError(
|
|
37
|
+
'navigation rule row cannot combine path and hint; use a standalone hint-only row'
|
|
38
|
+
)
|
|
39
|
+
return self
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class NavRulesGroup(BaseModel):
|
|
43
|
+
model_config = ConfigDict(populate_by_name=True)
|
|
44
|
+
|
|
45
|
+
rules: list[NavRuleRow] = Field(default_factory=list)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class NavigationFile(BaseModel):
|
|
49
|
+
model_config = ConfigDict(populate_by_name=True)
|
|
50
|
+
|
|
51
|
+
read_required: list[dict] = Field(default_factory=list, alias="read-required")
|
|
52
|
+
read_contextual: list[dict] = Field(default_factory=list, alias="read-contextual")
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: spawn-cli
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.16
|
|
4
4
|
Summary: A minimal installable Python CLI package.
|
|
5
5
|
License: MIT
|
|
6
6
|
Classifier: License :: OSI Approved :: MIT License
|
|
@@ -217,9 +217,11 @@ That rescans **`spawn/rules/`**, attaches missing rule files under a **`rules`**
|
|
|
217
217
|
|
|
218
218
|
### 2. Hints in `spawn/navigation.yaml`
|
|
219
219
|
|
|
220
|
-
For **short, always-visible reminders** (tone, language, repo-specific habits), add
|
|
220
|
+
For **short, always-visible reminders** (tone, language, repo-specific habits), add **hint-only** rows under **`read-required` → `rules`** (a **`hint`** entry **without** a **`path`**). Mandatory rule files use rows with **`path`** and **`description`** only—do **not** add **`hint`** on the same row as **`path`**; if refresh finds legacy pairs, it splits them into a file row and a following hint-only row.
|
|
221
221
|
|
|
222
|
-
|
|
222
|
+
Hints from **`read-required` → `rules`** (hint-only rows) appear in skills’ **Hints** section and in the managed **`AGENTS.md`** rollup together with extension **`hints.global`**, using YAML row order within maintainer hints before dedupe (**`hint`** on **`read-contextual` → `rules`** is still **not** included there).
|
|
223
|
+
|
|
224
|
+
If a **`path`** names a **`spawn/rules/…`** file that **does not** exist when navigation is refreshed, that row is **removed entirely** — fix or drop **`path`** first; **`hint`** on that same removed row is **not** downgraded to a hint-only row.
|
|
223
225
|
|
|
224
226
|
Example:
|
|
225
227
|
|
|
@@ -228,7 +230,8 @@ read-required:
|
|
|
228
230
|
- rules:
|
|
229
231
|
- path: spawn/rules/team.md
|
|
230
232
|
description: Team conventions.
|
|
231
|
-
|
|
233
|
+
- hint: Prefer British spelling in user-facing copy.
|
|
234
|
+
- hint: Short standalone reminder with no rule file.
|
|
232
235
|
```
|
|
233
236
|
|
|
234
237
|
Then run **`spawn refresh`** so skills and entry points pick up the hint.
|
|
@@ -16,6 +16,7 @@ src/spawn_cli/core/download.py
|
|
|
16
16
|
src/spawn_cli/core/errors.py
|
|
17
17
|
src/spawn_cli/core/high_level.py
|
|
18
18
|
src/spawn_cli/core/low_level.py
|
|
19
|
+
src/spawn_cli/core/mcp_stdio.py
|
|
19
20
|
src/spawn_cli/core/scripts.py
|
|
20
21
|
src/spawn_cli/ide/__init__.py
|
|
21
22
|
src/spawn_cli/ide/_helpers.py
|
|
@@ -26,6 +27,7 @@ src/spawn_cli/ide/codex.py
|
|
|
26
27
|
src/spawn_cli/ide/cursor.py
|
|
27
28
|
src/spawn_cli/ide/gemini_cli.py
|
|
28
29
|
src/spawn_cli/ide/github_copilot.py
|
|
30
|
+
src/spawn_cli/ide/mcp_stdio_argv.py
|
|
29
31
|
src/spawn_cli/ide/registry.py
|
|
30
32
|
src/spawn_cli/ide/windsurf.py
|
|
31
33
|
src/spawn_cli/io/__init__.py
|
|
@@ -349,3 +349,67 @@ def test_spawn_error_exit_code(tmp_path, monkeypatch, capsys):
|
|
|
349
349
|
assert main(["rules", "refresh"]) == 1
|
|
350
350
|
|
|
351
351
|
assert "planned failure" in capsys.readouterr().err
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
@contextlib.contextmanager
|
|
355
|
+
def _lock_must_not_be_used(_target_root: Path):
|
|
356
|
+
raise AssertionError("spawn_lock must not be used for mcp_stdio")
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def test_spawn_mcp_stdio_parses_nested_argv_fragments(tmp_path, monkeypatch):
|
|
360
|
+
"""Argv may arrive shell-fragmented; argparse still sees one token list."""
|
|
361
|
+
monkeypatch.chdir(tmp_path)
|
|
362
|
+
from spawn_cli.core import low_level as ll
|
|
363
|
+
|
|
364
|
+
ll.init(tmp_path)
|
|
365
|
+
root = tmp_path.resolve()
|
|
366
|
+
mock_run = MagicMock(return_value=0)
|
|
367
|
+
pieces = (["mcp_stdio", "extension"], ["myext"], ["name"], ["srv_name"])
|
|
368
|
+
argv = [tok for part in pieces for tok in part]
|
|
369
|
+
|
|
370
|
+
with patch("spawn_cli.cli.spawn_lock", _lock_must_not_be_used), patch(
|
|
371
|
+
"spawn_cli.cli.mcp_stdio.run_mcp_stdio_proxy", mock_run
|
|
372
|
+
):
|
|
373
|
+
assert main(argv) == 0
|
|
374
|
+
|
|
375
|
+
mock_run.assert_called_once_with(root, "myext", "srv_name")
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def test_spawn_mcp_stdio_never_calls_spawn_lock_factory(tmp_path, monkeypatch):
|
|
379
|
+
monkeypatch.chdir(tmp_path)
|
|
380
|
+
from spawn_cli.core import low_level as ll
|
|
381
|
+
|
|
382
|
+
ll.init(tmp_path)
|
|
383
|
+
root = tmp_path.resolve()
|
|
384
|
+
lock_factory = MagicMock()
|
|
385
|
+
mock_run = MagicMock(return_value=0)
|
|
386
|
+
with patch("spawn_cli.cli.spawn_lock", lock_factory), patch(
|
|
387
|
+
"spawn_cli.cli.mcp_stdio.run_mcp_stdio_proxy", mock_run
|
|
388
|
+
):
|
|
389
|
+
assert main(["mcp_stdio", "extension", "e1", "name", "n1"]) == 0
|
|
390
|
+
|
|
391
|
+
lock_factory.assert_not_called()
|
|
392
|
+
mock_run.assert_called_once_with(root, "e1", "n1")
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def test_spawn_mcp_stdio_parses_nested_argv(tmp_path, monkeypatch):
|
|
396
|
+
monkeypatch.chdir(tmp_path)
|
|
397
|
+
(tmp_path / "spawn").mkdir()
|
|
398
|
+
root = tmp_path.resolve()
|
|
399
|
+
mock_run = MagicMock(return_value=0)
|
|
400
|
+
|
|
401
|
+
with patch("spawn_cli.cli.spawn_lock", _lock_must_not_be_used), patch(
|
|
402
|
+
"spawn_cli.cli.mcp_stdio.run_mcp_stdio_proxy", mock_run
|
|
403
|
+
):
|
|
404
|
+
assert main(["mcp_stdio", "extension", "myext", "name", "srv"]) == 0
|
|
405
|
+
|
|
406
|
+
mock_run.assert_called_once_with(root, "myext", "srv")
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def test_spawn_mcp_stdio_help(capsys):
|
|
410
|
+
with pytest.raises(SystemExit) as exc:
|
|
411
|
+
main(["mcp_stdio", "--help"])
|
|
412
|
+
assert exc.value.code == 0
|
|
413
|
+
out = capsys.readouterr().out
|
|
414
|
+
assert "mcp_stdio" in out
|
|
415
|
+
assert "repository lock" in out.lower() or "lock" in out.lower()
|
|
@@ -1,30 +0,0 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
from pydantic import BaseModel, ConfigDict, Field
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
class NavFile(BaseModel):
|
|
7
|
-
model_config = ConfigDict(populate_by_name=True)
|
|
8
|
-
|
|
9
|
-
path: str
|
|
10
|
-
description: str
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
class NavExtGroup(BaseModel):
|
|
14
|
-
model_config = ConfigDict(populate_by_name=True)
|
|
15
|
-
|
|
16
|
-
ext: str
|
|
17
|
-
files: list[NavFile] = Field(default_factory=list)
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
class NavRulesGroup(BaseModel):
|
|
21
|
-
model_config = ConfigDict(populate_by_name=True)
|
|
22
|
-
|
|
23
|
-
rules: list[NavFile] = Field(default_factory=list)
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
class NavigationFile(BaseModel):
|
|
27
|
-
model_config = ConfigDict(populate_by_name=True)
|
|
28
|
-
|
|
29
|
-
read_required: list[dict] = Field(default_factory=list, alias="read-required")
|
|
30
|
-
read_contextual: list[dict] = Field(default_factory=list, alias="read-contextual")
|
|
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
|