spawn-cli 0.1.1__py3-none-any.whl

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.
@@ -0,0 +1,914 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ import shutil
5
+ import time
6
+ import uuid
7
+ import warnings
8
+ from collections import defaultdict
9
+ from collections.abc import Iterable
10
+ from importlib import resources
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ from ruamel.yaml import YAML
15
+
16
+ from spawn_cli.core.errors import SpawnError, SpawnWarning
17
+ from spawn_cli.io.json_io import load_json
18
+ from spawn_cli.io.paths import ensure_dir
19
+ from spawn_cli.io.text_io import read_lines, write_lines
20
+ from spawn_cli.io.yaml_io import configure_yaml_dump, load_yaml, save_yaml
21
+ from spawn_cli.models.config import ExtensionConfig, FileMode, IdeList, ReadFlag
22
+ from spawn_cli.models.mcp import McpCapabilities, McpEnvVar, McpServer, McpTransport, NormalizedMcp
23
+ from spawn_cli.models.skill import SkillFileRef, SkillMetadata, SkillRawInfo
24
+
25
+ CANONICAL_IDE_KEYS: tuple[str, ...] = (
26
+ "cursor",
27
+ "codex",
28
+ "qoder",
29
+ "claude-code",
30
+ "qwen-code",
31
+ "windsurf",
32
+ "github-copilot",
33
+ "aider",
34
+ "zed",
35
+ "gemini-cli",
36
+ "devin",
37
+ )
38
+
39
+ SPAWN_GITIGNORE_START = "# spawn:start"
40
+ SPAWN_GITIGNORE_END = "# spawn:end"
41
+
42
+
43
+ def _spawn(root: Path) -> Path:
44
+ return root / "spawn"
45
+
46
+
47
+ def _extend_root(root: Path) -> Path:
48
+ return _spawn(root) / ".extend"
49
+
50
+
51
+ def supported_ide_keys() -> list[str]:
52
+ return list(CANONICAL_IDE_KEYS)
53
+
54
+
55
+ def normalize_skill_name(name: str) -> str:
56
+ name = name.lower().strip()
57
+ name = re.sub(r"\s+", "-", name)
58
+ name = re.sub(r"[^a-z0-9._-]", "", name)
59
+ name = re.sub(r"-{2,}", "-", name)
60
+ return name
61
+
62
+
63
+ def list_extensions(target_root: Path) -> list[str]:
64
+ p = _extend_root(target_root)
65
+ if not p.is_dir():
66
+ return []
67
+ return sorted(d.name for d in p.iterdir() if d.is_dir())
68
+
69
+
70
+ def list_ides(target_root: Path) -> list[str]:
71
+ path = _spawn(target_root) / ".metadata" / "ide.yaml"
72
+ data = load_yaml(path)
73
+ model = IdeList.model_validate(data) if data else IdeList()
74
+ return list(model.ides)
75
+
76
+
77
+ def add_ide_to_list(target_root: Path, ide: str) -> None:
78
+ path = _spawn(target_root) / ".metadata" / "ide.yaml"
79
+ data = load_yaml(path)
80
+ model = IdeList.model_validate(data) if data else IdeList()
81
+ if ide not in model.ides:
82
+ model.ides.append(ide)
83
+ save_yaml(path, model.model_dump(by_alias=True, exclude_none=True))
84
+
85
+
86
+ def remove_ide_from_list(target_root: Path, ide: str) -> None:
87
+ path = _spawn(target_root) / ".metadata" / "ide.yaml"
88
+ data = load_yaml(path)
89
+ model = IdeList.model_validate(data) if data else IdeList()
90
+ model.ides = [x for x in model.ides if x != ide]
91
+ save_yaml(path, model.model_dump(by_alias=True, exclude_none=True))
92
+
93
+
94
+ METADATA_TEMP_MAX_AGE_SECONDS = 86400
95
+
96
+
97
+ def remove_ide_metadata_dir(target_root: Path, ide: str) -> None:
98
+ md = _spawn(target_root) / ".metadata" / ide
99
+ if md.is_dir():
100
+ shutil.rmtree(md, ignore_errors=True)
101
+
102
+
103
+ def prune_metadata_temp(parent: Path, *, max_age_seconds: int, reserved: str | None = None) -> None:
104
+ if not parent.is_dir():
105
+ return
106
+ now = time.time()
107
+ for child in sorted(parent.iterdir(), key=lambda p: p.name):
108
+ try:
109
+ if not child.is_dir():
110
+ continue
111
+ uuid.UUID(child.name)
112
+ except ValueError:
113
+ continue
114
+ except OSError:
115
+ continue
116
+ if reserved and child.name == reserved:
117
+ continue
118
+ try:
119
+ age = now - child.stat().st_mtime
120
+ if age > max_age_seconds:
121
+ shutil.rmtree(child, ignore_errors=True)
122
+ except OSError:
123
+ continue
124
+
125
+
126
+ def _config_path(target_root: Path, extension: str) -> Path:
127
+ return _extend_root(target_root) / extension / "config.yaml"
128
+
129
+
130
+ def _load_ext_config(target_root: Path, extension: str) -> ExtensionConfig:
131
+ path = _config_path(target_root, extension)
132
+ raw = load_yaml(path)
133
+ if not raw:
134
+ raise SpawnError(f"missing extension config: {extension!r}")
135
+ return ExtensionConfig.model_validate(raw)
136
+
137
+
138
+ def _file_refs_for_global(extension: ExtensionConfig, flag_required: bool) -> list[SkillFileRef]:
139
+ out: list[SkillFileRef] = []
140
+ for path_str, ent in extension.files.items():
141
+ if flag_required:
142
+ if ent.globalRead != ReadFlag.required:
143
+ continue
144
+ else:
145
+ if ent.globalRead != ReadFlag.auto:
146
+ continue
147
+ desc = ent.description or ""
148
+ out.append(SkillFileRef(file=path_str, description=desc))
149
+ return out
150
+
151
+
152
+ def _file_refs_for_local(extension: ExtensionConfig, flag_required: bool) -> list[SkillFileRef]:
153
+ out: list[SkillFileRef] = []
154
+ for path_str, ent in extension.files.items():
155
+ if flag_required:
156
+ if ent.localRead != ReadFlag.required:
157
+ continue
158
+ else:
159
+ if ent.localRead != ReadFlag.auto:
160
+ continue
161
+ desc = ent.description or ""
162
+ out.append(SkillFileRef(file=path_str, description=desc))
163
+ return out
164
+
165
+
166
+ def get_required_read_global(target_root: Path, extension: str) -> list[SkillFileRef]:
167
+ cfg = _load_ext_config(target_root, extension)
168
+ return _file_refs_for_global(cfg, True)
169
+
170
+
171
+ def get_required_read_global_all(target_root: Path) -> dict[str, list[SkillFileRef]]:
172
+ return {
173
+ ext: get_required_read_global(target_root, ext)
174
+ for ext in list_extensions(target_root)
175
+ }
176
+
177
+
178
+ def get_required_read_ext_local(target_root: Path, extension: str) -> list[SkillFileRef]:
179
+ cfg = _load_ext_config(target_root, extension)
180
+ return _file_refs_for_local(cfg, True)
181
+
182
+
183
+ def get_auto_read_global(target_root: Path, extension: str) -> list[SkillFileRef]:
184
+ cfg = _load_ext_config(target_root, extension)
185
+ return _file_refs_for_global(cfg, False)
186
+
187
+
188
+ def get_auto_read_global_all(target_root: Path) -> dict[str, list[SkillFileRef]]:
189
+ return {
190
+ ext: get_auto_read_global(target_root, ext)
191
+ for ext in list_extensions(target_root)
192
+ }
193
+
194
+
195
+ def get_auto_read_local(target_root: Path, extension: str) -> list[SkillFileRef]:
196
+ cfg = _load_ext_config(target_root, extension)
197
+ return _file_refs_for_local(cfg, False)
198
+
199
+
200
+ def get_folders(target_root: Path, extension: str) -> dict[str, Any]:
201
+ cfg = _load_ext_config(target_root, extension)
202
+ return {k: v.model_dump(by_alias=True) for k, v in cfg.folders.items()}
203
+
204
+
205
+ def get_removable(target_root: Path, extension: str) -> tuple[list[str], list[str]]:
206
+ cfg = _load_ext_config(target_root, extension)
207
+ static_files: list[str] = []
208
+ static_folders: list[str] = []
209
+ for p, ent in cfg.files.items():
210
+ if ent.mode == FileMode.static:
211
+ static_files.append(p)
212
+ for folder, ent in cfg.folders.items():
213
+ if ent.mode == FileMode.static:
214
+ static_folders.append(folder)
215
+ return static_files, static_folders
216
+
217
+
218
+ def list_skills(target_root: Path, extension: str) -> list[Path]:
219
+ root = _extend_root(target_root) / extension / "skills"
220
+ if not root.is_dir():
221
+ return []
222
+ return sorted(p for p in root.glob("*.md") if p.is_file())
223
+
224
+
225
+ def _parse_frontmatter(body: str) -> tuple[dict[str, Any], str]:
226
+ lines = body.splitlines(True)
227
+ if not lines:
228
+ return {}, body
229
+ if lines[0].strip() != "---":
230
+ return {}, body
231
+ end = None
232
+ for i in range(1, len(lines)):
233
+ if lines[i].strip() == "---":
234
+ end = i
235
+ break
236
+ if end is None:
237
+ return {}, body
238
+ fm_yaml = "".join(lines[1:end])
239
+ remainder = "".join(lines[end + 1 :])
240
+ yaml = YAML(typ="safe")
241
+ meta = yaml.load(fm_yaml)
242
+ meta_dict = meta if isinstance(meta, dict) else {}
243
+ return meta_dict, remainder
244
+
245
+
246
+ def _describe_path(cfg: ExtensionConfig, path_str: str) -> str:
247
+ ent = cfg.files.get(path_str)
248
+ return ent.description or "" if ent else ""
249
+
250
+
251
+ def get_skill_raw_info(target_root: Path, extension: str, skill_path: Path) -> SkillRawInfo:
252
+ cfg = _load_ext_config(target_root, extension)
253
+ text = skill_path.read_text(encoding="utf-8")
254
+ fm, content = _parse_frontmatter(text)
255
+ key = skill_path.name
256
+ sk_entry = cfg.skills.get(key)
257
+ fm_name = fm.get("name")
258
+ fm_desc = fm.get("description")
259
+ name: str
260
+ if sk_entry and sk_entry.name:
261
+ name = sk_entry.name
262
+ elif fm_name is not None:
263
+ name = str(fm_name)
264
+ else:
265
+ name = key.replace(".md", "")
266
+ description = ""
267
+ if sk_entry and sk_entry.description:
268
+ description = sk_entry.description
269
+ elif fm_desc is not None:
270
+ description = str(fm_desc)
271
+ req_read: list[str] = []
272
+ if sk_entry and sk_entry.required_read:
273
+ req_read = list(sk_entry.required_read)
274
+ return SkillRawInfo(
275
+ name=name,
276
+ description=description,
277
+ content=content,
278
+ required_read=req_read,
279
+ )
280
+
281
+
282
+ def _flatten_global_refs_ordered(
283
+ target_root: Path, per_ext: dict[str, list[SkillFileRef]]
284
+ ) -> list[SkillFileRef]:
285
+ out: list[SkillFileRef] = []
286
+ seen: set[str] = set()
287
+ for ext_id in list_extensions(target_root):
288
+ for ref in per_ext.get(ext_id, []):
289
+ nk = _norm_read_path(ref.file)
290
+ if nk in seen:
291
+ continue
292
+ seen.add(nk)
293
+ out.append(ref)
294
+ return out
295
+
296
+
297
+ def _norm_read_path(path_str: str) -> str:
298
+ """Normalize relative paths when comparing skill required vs contextual lists."""
299
+ return Path(path_str).as_posix().replace("\\", "/")
300
+
301
+
302
+ def _required_read_description_for_path(
303
+ p: str, merged_global: list[SkillFileRef], local_req: list[SkillFileRef], cfg_ext: ExtensionConfig
304
+ ) -> str:
305
+ for r in merged_global:
306
+ if r.file == p and r.description:
307
+ return r.description
308
+ for r in local_req:
309
+ if r.file == p and r.description:
310
+ return r.description
311
+ return _describe_path(cfg_ext, p)
312
+
313
+
314
+ def generate_skills_metadata(target_root: Path, extension: str) -> list[SkillMetadata]:
315
+ cfg_ext = _load_ext_config(target_root, extension)
316
+ merged_global_required = _flatten_global_refs_ordered(
317
+ target_root, get_required_read_global_all(target_root)
318
+ )
319
+ merged_global_auto = _flatten_global_refs_ordered(target_root, get_auto_read_global_all(target_root))
320
+ local_required = get_required_read_ext_local(target_root, extension)
321
+ local_auto = get_auto_read_local(target_root, extension)
322
+
323
+ metas: list[SkillMetadata] = []
324
+ for skill_path in list_skills(target_root, extension):
325
+ raw = get_skill_raw_info(target_root, extension, skill_path)
326
+ required_paths: list[str] = []
327
+ seen_norm: set[str] = set()
328
+ for blob in (
329
+ list(raw.required_read),
330
+ [r.file for r in local_required],
331
+ [r.file for r in merged_global_required],
332
+ ):
333
+ for fp in blob:
334
+ nk = _norm_read_path(fp)
335
+ if nk in seen_norm:
336
+ continue
337
+ seen_norm.add(nk)
338
+ required_paths.append(fp)
339
+ required_out = [
340
+ SkillFileRef(
341
+ file=p,
342
+ description=_required_read_description_for_path(p, merged_global_required, local_required, cfg_ext),
343
+ )
344
+ for p in required_paths
345
+ ]
346
+
347
+ required_key_set = {_norm_read_path(p) for p in required_paths}
348
+
349
+ auto_out: list[SkillFileRef] = []
350
+ seen_auto: set[str] = set()
351
+ for ar in local_auto:
352
+ key = _norm_read_path(ar.file)
353
+ if key in required_key_set or key in seen_auto:
354
+ continue
355
+ seen_auto.add(key)
356
+ auto_out.append(ar)
357
+ for ar in merged_global_auto:
358
+ key = _norm_read_path(ar.file)
359
+ if key in required_key_set or key in seen_auto:
360
+ continue
361
+ seen_auto.add(key)
362
+ auto_out.append(ar)
363
+
364
+ metas.append(
365
+ SkillMetadata(
366
+ name=raw.name,
367
+ description=raw.description,
368
+ content=raw.content,
369
+ required_read=required_out,
370
+ auto_read=auto_out,
371
+ )
372
+ )
373
+ return metas
374
+
375
+
376
+ def validate_rendered_identity(target_root: Path) -> None:
377
+ exts = list_extensions(target_root)
378
+ skill_claims: dict[str, list[str]] = defaultdict(list)
379
+ mcp_claims: dict[str, list[str]] = defaultdict(list)
380
+
381
+ for ext in exts:
382
+ for meta in generate_skills_metadata(target_root, ext):
383
+ nk = normalize_skill_name(meta.name)
384
+ skill_claims[nk].append(ext)
385
+ nm = list_mcp(target_root, ext)
386
+ for srv in nm.servers:
387
+ mcp_claims[srv.name].append(ext)
388
+
389
+ for key, holders in skill_claims.items():
390
+ if len(holders) > 1:
391
+ uniq = sorted(set(holders))
392
+ raise SpawnError(
393
+ f"duplicate normalized skill names across extensions ({key!r}): extensions {uniq}"
394
+ )
395
+ for srv_name, holders in mcp_claims.items():
396
+ if len(holders) > 1:
397
+ uniq = sorted(set(holders))
398
+ raise SpawnError(
399
+ f"duplicate MCP server names across extensions ({srv_name!r}): extensions {uniq}"
400
+ )
401
+
402
+
403
+ def _parse_mcp_env(env_raw: dict[str, Any]) -> dict[str, McpEnvVar]:
404
+ out: dict[str, McpEnvVar] = {}
405
+ for k, v in env_raw.items():
406
+ if isinstance(v, dict):
407
+ out[str(k)] = McpEnvVar.model_validate(v)
408
+ else:
409
+ out[str(k)] = McpEnvVar(source="user", required=True, secret=True)
410
+ return out
411
+
412
+
413
+ def _mcp_capabilities(raw: dict[str, Any]) -> McpCapabilities:
414
+ c = raw.get("capabilities") or {}
415
+ return McpCapabilities.model_validate(c) if isinstance(c, dict) else McpCapabilities()
416
+
417
+
418
+ def list_mcp(target_root: Path, extension: str) -> NormalizedMcp:
419
+ path = _extend_root(target_root) / extension / "mcp.json"
420
+ data = load_json(path)
421
+ servers_out: list[McpServer] = []
422
+ for s in data.get("servers") or []:
423
+ if not isinstance(s, dict):
424
+ continue
425
+ nm = s.get("name")
426
+ if nm is None:
427
+ raise SpawnError(f"missing MCP server name in {path}")
428
+ tr_raw = s.get("transport") or {}
429
+ transport = McpTransport(
430
+ type=str(tr_raw.get("type", "stdio")),
431
+ command=tr_raw.get("command"),
432
+ args=list(tr_raw.get("args") or []),
433
+ cwd=str(tr_raw.get("cwd", ".")),
434
+ url=tr_raw.get("url"),
435
+ )
436
+ env_in = s.get("env") or {}
437
+ env = _parse_mcp_env(env_in) if isinstance(env_in, dict) else {}
438
+ servers_out.append(
439
+ McpServer(
440
+ name=str(nm),
441
+ extension=extension,
442
+ transport=transport,
443
+ env=env,
444
+ capabilities=_mcp_capabilities(s),
445
+ )
446
+ )
447
+ return NormalizedMcp(servers=servers_out)
448
+
449
+
450
+ def get_navigation_metadata(target_root: Path, extension: str) -> dict:
451
+ return {
452
+ "required": [x.model_dump(by_alias=True) for x in get_required_read_global(target_root, extension)],
453
+ "contextual": [x.model_dump(by_alias=True) for x in get_auto_read_global(target_root, extension)],
454
+ }
455
+
456
+
457
+ def get_core_agent_ignore(target_root: Path) -> list[str]:
458
+ path = _spawn(target_root) / ".core" / "config.yaml"
459
+ from spawn_cli.models.config import CoreConfig
460
+
461
+ data = load_yaml(path)
462
+ if not data:
463
+ return []
464
+ core = CoreConfig.model_validate(data)
465
+ return list(core.agent_ignore)
466
+
467
+
468
+ def get_ext_agent_ignore(target_root: Path, extension: str) -> list[str]:
469
+ cfg = _load_ext_config(target_root, extension)
470
+ return list(cfg.agent_ignore)
471
+
472
+
473
+ def get_all_agent_ignore(target_root: Path) -> list[str]:
474
+ merged: list[str] = []
475
+ seen: set[str] = set()
476
+ for g in get_core_agent_ignore(target_root):
477
+ if g not in seen:
478
+ seen.add(g)
479
+ merged.append(g)
480
+ for ext in list_extensions(target_root):
481
+ for g in get_ext_agent_ignore(target_root, ext):
482
+ if g not in seen:
483
+ seen.add(g)
484
+ merged.append(g)
485
+ return merged
486
+
487
+
488
+ def get_ext_git_ignore(target_root: Path, extension: str) -> list[str]:
489
+ cfg = _load_ext_config(target_root, extension)
490
+ return list(cfg.git_ignore)
491
+
492
+
493
+ def _rendered_skills_path(target_root: Path, ide: str) -> Path:
494
+ return _spawn(target_root) / ".metadata" / ide / "rendered-skills.yaml"
495
+
496
+
497
+ def _rendered_mcp_path(target_root: Path, ide: str) -> Path:
498
+ return _spawn(target_root) / ".metadata" / ide / "rendered-mcp.yaml"
499
+
500
+
501
+ def save_skills_rendered(
502
+ target_root: Path, ide: str, extension: str, skill_paths: list[dict[str, Any]]
503
+ ) -> None:
504
+ path = _rendered_skills_path(target_root, ide)
505
+ ensure_dir(path.parent)
506
+ data = load_yaml(path) or {}
507
+ exts = dict(data.get("extensions") or {}) if isinstance(data.get("extensions"), dict) else {}
508
+ if not skill_paths:
509
+ exts.pop(extension, None)
510
+ else:
511
+ exts[extension] = list(skill_paths)
512
+ data["extensions"] = exts
513
+ save_yaml(path, data)
514
+
515
+
516
+ def get_rendered_skills(target_root: Path, ide: str, extension: str) -> list[dict]:
517
+ path = _rendered_skills_path(target_root, ide)
518
+ raw = load_yaml(path)
519
+ if not raw:
520
+ return []
521
+ exts = raw.get("extensions") or {}
522
+ if not isinstance(exts, dict):
523
+ return []
524
+ block = exts.get(extension)
525
+ return list(block) if isinstance(block, list) else []
526
+
527
+
528
+ def save_mcp_rendered(target_root: Path, ide: str, extension: str, mcp_names: list[str]) -> None:
529
+ path = _rendered_mcp_path(target_root, ide)
530
+ ensure_dir(path.parent)
531
+ data = load_yaml(path) or {}
532
+ exts = dict(data.get("extensions") or {}) if isinstance(data.get("extensions"), dict) else {}
533
+ if not mcp_names:
534
+ exts.pop(extension, None)
535
+ else:
536
+ exts[extension] = [{"name": n} for n in mcp_names]
537
+ data["extensions"] = exts
538
+ save_yaml(path, data)
539
+
540
+
541
+ def get_rendered_mcp(target_root: Path, ide: str, extension: str) -> list[str]:
542
+ path = _rendered_mcp_path(target_root, ide)
543
+ raw = load_yaml(path)
544
+ if not raw:
545
+ return []
546
+ exts = raw.get("extensions") or {}
547
+ if not isinstance(exts, dict):
548
+ return []
549
+ block = exts.get(extension)
550
+ if not isinstance(block, list):
551
+ return []
552
+ names: list[str] = []
553
+ for item in block:
554
+ if isinstance(item, dict) and "name" in item:
555
+ names.append(str(item["name"]))
556
+ elif isinstance(item, str):
557
+ names.append(item)
558
+ return names
559
+
560
+
561
+ def _git_ignore_list_path(target_root: Path) -> Path:
562
+ return _spawn(target_root) / ".metadata" / "git-ignore.txt"
563
+
564
+
565
+ def get_git_ignore_list(target_root: Path) -> list[str]:
566
+ lines = read_lines(_git_ignore_list_path(target_root))
567
+ return [ln for ln in lines if ln.strip() and not ln.lstrip().startswith("#")]
568
+
569
+
570
+ def save_git_ignore_list(target_root: Path, items: list[str]) -> None:
571
+ write_lines(_git_ignore_list_path(target_root), list(items))
572
+
573
+
574
+ def _agent_ignore_list_path(target_root: Path, ide: str) -> Path:
575
+ return _spawn(target_root) / ".metadata" / ide / "agent-ignore.txt"
576
+
577
+
578
+ def get_agent_ignore_list(target_root: Path, ide: str) -> list[str]:
579
+ lines = read_lines(_agent_ignore_list_path(target_root, ide))
580
+ return [ln for ln in lines if ln.strip() and not ln.lstrip().startswith("#")]
581
+
582
+
583
+ def save_agent_ignore_list(target_root: Path, ide: str, items: list[str]) -> None:
584
+ path = _agent_ignore_list_path(target_root, ide)
585
+ ensure_dir(path.parent)
586
+ write_lines(path, list(items))
587
+
588
+
589
+ def get_global_gitignore(target_root: Path) -> list[str]:
590
+ git_path = target_root / ".gitignore"
591
+ if not git_path.is_file():
592
+ return []
593
+ return read_lines(git_path)
594
+
595
+
596
+ def _partition_gitignore(lines: list[str]) -> tuple[list[str], list[str], list[str]]:
597
+ start_idx: int | None = None
598
+ end_idx: int | None = None
599
+ for i, ln in enumerate(lines):
600
+ if ln.strip() == SPAWN_GITIGNORE_START:
601
+ start_idx = i
602
+ break
603
+ if start_idx is None:
604
+ return lines[:], [], []
605
+ for j in range(start_idx + 1, len(lines)):
606
+ if lines[j].strip() == SPAWN_GITIGNORE_END:
607
+ end_idx = j
608
+ break
609
+ if end_idx is None:
610
+ return lines[:start_idx], lines[start_idx + 1 :], []
611
+ return lines[:start_idx], lines[start_idx + 1 : end_idx], lines[end_idx + 1 :]
612
+
613
+
614
+ def push_to_global_gitignore(target_root: Path, items: list[str]) -> None:
615
+ path = target_root / ".gitignore"
616
+ raw = read_lines(path)
617
+ before, interior, after = _partition_gitignore(raw)
618
+ ordered: list[str] = []
619
+ seen: set[str] = set()
620
+ for ln in interior:
621
+ st = ln.strip()
622
+ if not st:
623
+ continue
624
+ if st.startswith("#"):
625
+ continue
626
+ if st not in seen:
627
+ seen.add(st)
628
+ ordered.append(st)
629
+ for it in items:
630
+ st = str(it).strip()
631
+ if st and st not in seen:
632
+ seen.add(st)
633
+ ordered.append(st)
634
+ want_block = bool(ordered)
635
+ new_lines: list[str] = []
636
+ new_lines.extend(before)
637
+ if want_block:
638
+ new_lines.append(SPAWN_GITIGNORE_START)
639
+ new_lines.extend(ordered)
640
+ new_lines.append(SPAWN_GITIGNORE_END)
641
+ new_lines.extend(after)
642
+ ensure_dir(target_root)
643
+ write_lines(path, new_lines)
644
+
645
+
646
+ def remove_from_global_gitignore(target_root: Path, items: list[str]) -> None:
647
+ path = target_root / ".gitignore"
648
+ raw = read_lines(path)
649
+ before, interior, after = _partition_gitignore(raw)
650
+ remove_me = {x.strip() for x in items if x.strip()}
651
+ kept_interior = [
652
+ ln
653
+ for ln in interior
654
+ if not (
655
+ ln.strip()
656
+ and not ln.strip().startswith("#")
657
+ and ln.strip() in remove_me
658
+ )
659
+ ]
660
+ substantive = [
661
+ ln for ln in kept_interior if ln.strip() and not ln.strip().startswith("#")
662
+ ]
663
+ new_lines = list(before)
664
+ if substantive:
665
+ new_lines.append(SPAWN_GITIGNORE_START)
666
+ new_lines.extend(kept_interior)
667
+ new_lines.append(SPAWN_GITIGNORE_END)
668
+ new_lines.extend(after)
669
+ ensure_dir(target_root)
670
+ if raw or new_lines:
671
+ write_lines(path, new_lines)
672
+
673
+
674
+ def _nav_refs_to_files(refs: list[SkillFileRef]) -> list[dict[str, str]]:
675
+ return [{"path": ref.file, "description": ref.description} for ref in refs]
676
+
677
+
678
+ def _strip_ext_sections_inplace(groups: list[Any], extension: str) -> None:
679
+ for i in range(len(groups) - 1, -1, -1):
680
+ item = groups[i]
681
+ if isinstance(item, dict) and item.get("ext") == extension:
682
+ del groups[i]
683
+
684
+
685
+ def _desired_navigation_root_key_order(keys: Iterable[str]) -> list[str]:
686
+ """Canonical top-level navigation keys: read-required, read-contextual, then unknown keys."""
687
+
688
+ ks = list(keys)
689
+ others = [k for k in ks if k not in ("read-required", "read-contextual")]
690
+ ordered: list[str] = []
691
+ if "read-required" in ks:
692
+ ordered.append("read-required")
693
+ if "read-contextual" in ks:
694
+ ordered.append("read-contextual")
695
+ ordered.extend(others)
696
+ return ordered
697
+
698
+
699
+ def _ensure_navigation_root_key_order(nav: Any) -> None:
700
+ """Reorder root keys (required before contextual). CommentedMap in-place; plain dict rebuilt."""
701
+
702
+ if not isinstance(nav, dict):
703
+ return
704
+ desired = _desired_navigation_root_key_order(nav.keys())
705
+ if list(nav.keys()) == desired:
706
+ return
707
+ mover = getattr(nav, "move_to_end", None)
708
+ if mover is not None:
709
+ for key in reversed(desired):
710
+ if key in nav:
711
+ mover(key, last=False)
712
+ return
713
+ reordered = {k: nav[k] for k in desired}
714
+ nav.clear()
715
+ nav.update(reordered)
716
+
717
+
718
+ def save_extension_navigation(
719
+ target_root: Path,
720
+ extension: str,
721
+ read_required_files: list[SkillFileRef],
722
+ read_contextual_files: list[SkillFileRef],
723
+ ) -> None:
724
+ nav_path = _spawn(target_root) / "navigation.yaml"
725
+ yaml_rt = YAML(typ="rt")
726
+ configure_yaml_dump(yaml_rt)
727
+ if nav_path.is_file():
728
+ with nav_path.open("r", encoding="utf-8") as fh:
729
+ raw = yaml_rt.load(fh)
730
+ else:
731
+ raw = None
732
+ if raw is None or not isinstance(raw, dict):
733
+ from ruamel.yaml.comments import CommentedMap
734
+
735
+ raw = CommentedMap()
736
+ rr = raw.get("read-required")
737
+ if not isinstance(rr, list):
738
+ rr = []
739
+ raw["read-required"] = rr
740
+ rc = raw.get("read-contextual")
741
+ if not isinstance(rc, list):
742
+ rc = []
743
+ raw["read-contextual"] = rc
744
+ _strip_ext_sections_inplace(rr, extension)
745
+ _strip_ext_sections_inplace(rc, extension)
746
+ if read_required_files:
747
+ rr.append({"ext": extension, "files": _nav_refs_to_files(read_required_files)})
748
+ if read_contextual_files:
749
+ req_norm = {_norm_read_path(r.file) for r in read_required_files}
750
+ contextual_only = [
751
+ r for r in read_contextual_files if _norm_read_path(r.file) not in req_norm
752
+ ]
753
+ if contextual_only:
754
+ rc.append({"ext": extension, "files": _nav_refs_to_files(contextual_only)})
755
+ _ensure_navigation_root_key_order(raw)
756
+ ensure_dir(nav_path.parent)
757
+ with nav_path.open("w", encoding="utf-8") as fh:
758
+ yaml_rt.dump(raw, fh)
759
+
760
+
761
+
762
+ def save_rules_navigation(target_root: Path) -> None:
763
+ nav_path = _spawn(target_root) / 'navigation.yaml'
764
+ raw = load_yaml(nav_path) or {}
765
+ rr: list[Any] = list(raw['read-required']) if isinstance(raw.get('read-required'), list) else []
766
+ rc: list[Any] = list(raw.get('read-contextual')) if isinstance(raw.get('read-contextual'), list) else []
767
+
768
+ rule_dir = _spawn(target_root) / 'rules'
769
+ paths_on_disk: set[str] = set()
770
+ if rule_dir.is_dir():
771
+ for f in sorted(rule_dir.rglob('*')):
772
+ if f.is_file():
773
+ rel_posix = (Path('spawn/rules') / f.relative_to(rule_dir).as_posix()).as_posix()
774
+ paths_on_disk.add(rel_posix.replace('\\', '/'))
775
+
776
+ def locate_or_create_rules_list(section: list[Any]) -> list[Any]:
777
+ for grp in section:
778
+ if isinstance(grp, dict) and isinstance(grp.get('rules'), list):
779
+ return grp['rules']
780
+ section.append({'rules': []})
781
+ tail = section[-1]
782
+ if not isinstance(tail, dict):
783
+ raise SpawnError('internal navigation shape error')
784
+ return tail['rules']
785
+
786
+ def rule_paths_union(*maybe_lists: list[Any] | None) -> set[str]:
787
+ out: set[str] = set()
788
+ for lst in maybe_lists:
789
+ if not lst:
790
+ continue
791
+ for item in lst:
792
+ if isinstance(item, dict) and 'path' in item:
793
+ out.add(Path(str(item['path'])).as_posix().replace('\\', '/'))
794
+ return out
795
+
796
+ rq_rules_list = locate_or_create_rules_list(rr)
797
+
798
+ cq_rules_list: list[Any] | None = None
799
+ for grp in rc:
800
+ if isinstance(grp, dict) and isinstance(grp.get('rules'), list):
801
+ cq_rules_list = grp['rules']
802
+ break
803
+
804
+ known_paths = rule_paths_union(rq_rules_list, cq_rules_list)
805
+
806
+ for dk in sorted(paths_on_disk):
807
+ if dk not in known_paths:
808
+ rq_rules_list.append({'path': dk, 'description': 'Local rule file.'})
809
+
810
+ def posix_norm(p: str) -> str:
811
+ return Path(p).as_posix().replace('\\', '/')
812
+
813
+ def prune(lst: list[Any]) -> None:
814
+ out: list[Any] = []
815
+ for entry in lst:
816
+ if not isinstance(entry, dict) or 'path' not in entry:
817
+ continue
818
+ rk = posix_norm(str(entry['path']))
819
+ exists = rk in paths_on_disk and (Path(target_root) / Path(rk)).is_file()
820
+ if exists:
821
+ out.append({'path': rk, 'description': str(entry.get('description') or 'Local rule file.')})
822
+ else:
823
+ warnings.warn(
824
+ f"Removed missing rule path from navigation: {rk}",
825
+ SpawnWarning,
826
+ )
827
+ lst[:] = out
828
+
829
+ prune(rq_rules_list)
830
+ if cq_rules_list is not None:
831
+ prune(cq_rules_list)
832
+
833
+ raw["read-required"] = rr
834
+ raw["read-contextual"] = rc
835
+ _ensure_navigation_root_key_order(raw)
836
+ save_yaml(nav_path, raw)
837
+
838
+
839
+ def init(target_root: Path) -> None:
840
+ s = target_root / 'spawn'
841
+ ensure_dir(s)
842
+ ensure_dir(s / '.core')
843
+
844
+ cfg = s / '.core' / 'config.yaml'
845
+ if not cfg.is_file():
846
+ template = resources.files('spawn_cli.resources').joinpath('default_core_config.yaml').read_text(encoding='utf-8')
847
+ cfg.write_text(template, encoding='utf-8')
848
+
849
+ md = s / '.metadata'
850
+ ensure_dir(md)
851
+
852
+ ide_yaml = md / 'ide.yaml'
853
+ if not ide_yaml.is_file():
854
+ save_yaml(ide_yaml, IdeList().model_dump(by_alias=True, exclude_none=True))
855
+
856
+ gif = md / 'git-ignore.txt'
857
+ if not gif.is_file():
858
+ write_lines(gif, [])
859
+
860
+ ensure_dir(s / 'rules')
861
+
862
+ nav = s / 'navigation.yaml'
863
+ if not nav.is_file():
864
+ save_yaml(nav, {'read-required': [], 'read-contextual': []})
865
+
866
+ push_to_global_gitignore(target_root, ["spawn/.metadata/temp/**"])
867
+
868
+
869
+
870
+ __all__ = [
871
+ "CANONICAL_IDE_KEYS",
872
+ "add_ide_to_list",
873
+ "generate_skills_metadata",
874
+ "get_agent_ignore_list",
875
+ "get_all_agent_ignore",
876
+ "get_auto_read_global",
877
+ "get_auto_read_global_all",
878
+ "get_auto_read_local",
879
+ "get_core_agent_ignore",
880
+ "get_ext_agent_ignore",
881
+ "get_ext_git_ignore",
882
+ "get_folders",
883
+ "get_git_ignore_list",
884
+ "get_global_gitignore",
885
+ "get_navigation_metadata",
886
+ "get_rendered_mcp",
887
+ "get_rendered_skills",
888
+ "get_removable",
889
+ "get_required_read_ext_local",
890
+ "get_required_read_global",
891
+ "get_required_read_global_all",
892
+ "get_skill_raw_info",
893
+ "init",
894
+ "list_extensions",
895
+ "list_ides",
896
+ "list_mcp",
897
+ "list_skills",
898
+ "METADATA_TEMP_MAX_AGE_SECONDS",
899
+ "normalize_skill_name",
900
+ "prune_metadata_temp",
901
+ "push_to_global_gitignore",
902
+ "remove_from_global_gitignore",
903
+ "remove_ide_metadata_dir",
904
+ "remove_ide_from_list",
905
+ "save_agent_ignore_list",
906
+ "save_extension_navigation",
907
+ "save_git_ignore_list",
908
+ "save_mcp_rendered",
909
+ "save_rules_navigation",
910
+ "save_skills_rendered",
911
+ "supported_ide_keys",
912
+ "validate_rendered_identity",
913
+ ]
914
+