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.
Files changed (52) hide show
  1. {spawn_cli-0.1.14/src/spawn_cli.egg-info → spawn_cli-0.1.16}/PKG-INFO +7 -4
  2. {spawn_cli-0.1.14 → spawn_cli-0.1.16}/README.md +6 -3
  3. {spawn_cli-0.1.14 → spawn_cli-0.1.16}/pyproject.toml +1 -1
  4. {spawn_cli-0.1.14 → spawn_cli-0.1.16}/src/spawn_cli/cli.py +42 -0
  5. {spawn_cli-0.1.14 → spawn_cli-0.1.16}/src/spawn_cli/core/high_level.py +9 -0
  6. {spawn_cli-0.1.14 → spawn_cli-0.1.16}/src/spawn_cli/core/low_level.py +95 -6
  7. spawn_cli-0.1.16/src/spawn_cli/core/mcp_stdio.py +67 -0
  8. {spawn_cli-0.1.14 → spawn_cli-0.1.16}/src/spawn_cli/ide/__init__.py +2 -0
  9. {spawn_cli-0.1.14 → spawn_cli-0.1.16}/src/spawn_cli/ide/claude_code.py +5 -1
  10. {spawn_cli-0.1.14 → spawn_cli-0.1.16}/src/spawn_cli/ide/codex.py +7 -2
  11. {spawn_cli-0.1.14 → spawn_cli-0.1.16}/src/spawn_cli/ide/cursor.py +5 -1
  12. {spawn_cli-0.1.14 → spawn_cli-0.1.16}/src/spawn_cli/ide/gemini_cli.py +7 -2
  13. {spawn_cli-0.1.14 → spawn_cli-0.1.16}/src/spawn_cli/ide/github_copilot.py +7 -2
  14. spawn_cli-0.1.16/src/spawn_cli/ide/mcp_stdio_argv.py +10 -0
  15. {spawn_cli-0.1.14 → spawn_cli-0.1.16}/src/spawn_cli/models/__init__.py +2 -0
  16. {spawn_cli-0.1.14 → spawn_cli-0.1.16}/src/spawn_cli/models/mcp.py +2 -1
  17. spawn_cli-0.1.16/src/spawn_cli/models/navigation.py +52 -0
  18. {spawn_cli-0.1.14 → spawn_cli-0.1.16/src/spawn_cli.egg-info}/PKG-INFO +7 -4
  19. {spawn_cli-0.1.14 → spawn_cli-0.1.16}/src/spawn_cli.egg-info/SOURCES.txt +2 -0
  20. {spawn_cli-0.1.14 → spawn_cli-0.1.16}/tests/test_cli.py +64 -0
  21. spawn_cli-0.1.14/src/spawn_cli/models/navigation.py +0 -30
  22. {spawn_cli-0.1.14 → spawn_cli-0.1.16}/LICENSE +0 -0
  23. {spawn_cli-0.1.14 → spawn_cli-0.1.16}/setup.cfg +0 -0
  24. {spawn_cli-0.1.14 → spawn_cli-0.1.16}/src/spawn_cli/__init__.py +0 -0
  25. {spawn_cli-0.1.14 → spawn_cli-0.1.16}/src/spawn_cli/core/__init__.py +0 -0
  26. {spawn_cli-0.1.14 → spawn_cli-0.1.16}/src/spawn_cli/core/download.py +0 -0
  27. {spawn_cli-0.1.14 → spawn_cli-0.1.16}/src/spawn_cli/core/errors.py +0 -0
  28. {spawn_cli-0.1.14 → spawn_cli-0.1.16}/src/spawn_cli/core/scripts.py +0 -0
  29. {spawn_cli-0.1.14 → spawn_cli-0.1.16}/src/spawn_cli/errors.py +0 -0
  30. {spawn_cli-0.1.14 → spawn_cli-0.1.16}/src/spawn_cli/ide/_helpers.py +0 -0
  31. {spawn_cli-0.1.14 → spawn_cli-0.1.16}/src/spawn_cli/ide/_stub.py +0 -0
  32. {spawn_cli-0.1.14 → spawn_cli-0.1.16}/src/spawn_cli/ide/_vacancy.py +0 -0
  33. {spawn_cli-0.1.14 → spawn_cli-0.1.16}/src/spawn_cli/ide/registry.py +0 -0
  34. {spawn_cli-0.1.14 → spawn_cli-0.1.16}/src/spawn_cli/ide/windsurf.py +0 -0
  35. {spawn_cli-0.1.14 → spawn_cli-0.1.16}/src/spawn_cli/io/__init__.py +0 -0
  36. {spawn_cli-0.1.14 → spawn_cli-0.1.16}/src/spawn_cli/io/json_io.py +0 -0
  37. {spawn_cli-0.1.14 → spawn_cli-0.1.16}/src/spawn_cli/io/lock.py +0 -0
  38. {spawn_cli-0.1.14 → spawn_cli-0.1.16}/src/spawn_cli/io/paths.py +0 -0
  39. {spawn_cli-0.1.14 → spawn_cli-0.1.16}/src/spawn_cli/io/text_io.py +0 -0
  40. {spawn_cli-0.1.14 → spawn_cli-0.1.16}/src/spawn_cli/io/toml_io.py +0 -0
  41. {spawn_cli-0.1.14 → spawn_cli-0.1.16}/src/spawn_cli/io/yaml_io.py +0 -0
  42. {spawn_cli-0.1.14 → spawn_cli-0.1.16}/src/spawn_cli/models/config.py +0 -0
  43. {spawn_cli-0.1.14 → spawn_cli-0.1.16}/src/spawn_cli/models/metadata.py +0 -0
  44. {spawn_cli-0.1.14 → spawn_cli-0.1.16}/src/spawn_cli/models/skill.py +0 -0
  45. {spawn_cli-0.1.14 → spawn_cli-0.1.16}/src/spawn_cli/resources/__init__.py +0 -0
  46. {spawn_cli-0.1.14 → spawn_cli-0.1.16}/src/spawn_cli/resources/default_core_config.yaml +0 -0
  47. {spawn_cli-0.1.14 → spawn_cli-0.1.16}/src/spawn_cli/warnings_display.py +0 -0
  48. {spawn_cli-0.1.14 → spawn_cli-0.1.16}/src/spawn_cli.egg-info/dependency_links.txt +0 -0
  49. {spawn_cli-0.1.14 → spawn_cli-0.1.16}/src/spawn_cli.egg-info/entry_points.txt +0 -0
  50. {spawn_cli-0.1.14 → spawn_cli-0.1.16}/src/spawn_cli.egg-info/requires.txt +0 -0
  51. {spawn_cli-0.1.14 → spawn_cli-0.1.16}/src/spawn_cli.egg-info/top_level.txt +0 -0
  52. {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.14
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 an optional **`hint`** field on rows under **`read-required` → `rules`**—alongside **`path`** and **`description`**.
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
- Spawn merges those strings into the **Hints** section of **rendered skills** and into the **Hints** rollup in managed IDE entry points (for example **`AGENTS.md`**), using the same ordering and deduping rules as extension **`hints.global`**.
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
- hint: Prefer British spelling in user-facing copy.
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 an optional **`hint`** field on rows under **`read-required` → `rules`**—alongside **`path`** and **`description`**.
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
- Spawn merges those strings into the **Hints** section of **rendered skills** and into the **Hints** rollup in managed IDE entry points (for example **`AGENTS.md`**), using the same ordering and deduping rules as extension **`hints.global`**.
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
- hint: Prefer British spelling in user-facing copy.
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.
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "spawn-cli"
7
- version = "0.1.14"
7
+ version = "0.1.16"
8
8
  description = "A minimal installable Python CLI package."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -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 only (YAML order).
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
- out.append(hint_val)
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=str(nm),
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 prune(lst: list[Any]) -> None:
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
- prune(rq_rules_list)
1293
+ prune_read_required_rules(rq_rules_list)
1205
1294
  if cq_rules_list is not None:
1206
- prune(cq_rules_list)
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
- entry: dict = {"command": transport.command, "args": list(transport.args)}
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
- entry["command"] = transport.command
28
- entry["args"] = list(transport.args)
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
- entry: dict = {"command": transport.command, "args": transport.args}
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
- entry["command"] = transport.command
35
- entry["args"] = list(transport.args)
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
- entry["command"] = transport.command
35
- entry["args"] = list(transport.args)
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.14
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 an optional **`hint`** field on rows under **`read-required` → `rules`**—alongside **`path`** and **`description`**.
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
- Spawn merges those strings into the **Hints** section of **rendered skills** and into the **Hints** rollup in managed IDE entry points (for example **`AGENTS.md`**), using the same ordering and deduping rules as extension **`hints.global`**.
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
- hint: Prefer British spelling in user-facing copy.
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