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,532 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ import shutil
5
+ import subprocess
6
+ import tempfile
7
+ import uuid
8
+ import warnings
9
+ import zipfile
10
+ from collections import defaultdict
11
+ from dataclasses import dataclass
12
+ from datetime import datetime, timezone
13
+ from pathlib import Path
14
+ from typing import Any
15
+ from urllib.parse import urlparse
16
+
17
+ import httpx
18
+
19
+ from ruamel.yaml import YAML
20
+
21
+ from spawn_cli.core import low_level as ll
22
+ from spawn_cli.core.errors import SpawnError, SpawnWarning
23
+ from spawn_cli.core import scripts
24
+ from spawn_cli.io.json_io import load_json
25
+ from spawn_cli.io.paths import ensure_dir, safe_path
26
+ from spawn_cli.io.yaml_io import load_yaml, save_yaml
27
+ from spawn_cli.models.config import ExtensionConfig, ExtensionsMeta, FileMode, SourceYaml
28
+
29
+ SAFE_YAML = YAML(typ="safe")
30
+
31
+ GIT_INSTALL_HINT = (
32
+ "Install Git for your OS: Windows (winget install Git.Git), "
33
+ "macOS (brew install git or Xcode CLT), "
34
+ "Linux (sudo apt install git or sudo dnf install git)."
35
+ )
36
+
37
+
38
+ def compare_version_strings(a: str, b: str) -> int:
39
+ """Return 1 if a > b, 0 if equal, -1 if a < b.
40
+
41
+ Numeric segments (dot-separated leading part) compared left to right as integers.
42
+ Remaining suffix compared lexicographically after numeric parts are tied.
43
+ """
44
+
45
+ def split_ver(s: str) -> tuple[list[int], str]:
46
+ s = s.strip()
47
+ m = re.match(r"^((?:\d+\.)*\d+)(.*)$", s)
48
+ if not m:
49
+ return [], s
50
+ num, suf = m.group(1), m.group(2)
51
+ parts = [int(x) for x in num.split(".") if x != ""]
52
+ return parts, suf
53
+
54
+ pa, sa = split_ver(a)
55
+ pb, sb = split_ver(b)
56
+ n = max(len(pa), len(pb))
57
+ for i in range(n):
58
+ va = pa[i] if i < len(pa) else 0
59
+ vb = pb[i] if i < len(pb) else 0
60
+ if va < vb:
61
+ return -1
62
+ if va > vb:
63
+ return 1
64
+ if sa < sb:
65
+ return -1
66
+ if sa > sb:
67
+ return 1
68
+ return 0
69
+
70
+
71
+ def _require_init(target_root: Path) -> None:
72
+ if not (target_root / "spawn").is_dir():
73
+ raise SpawnError("need init before running this command")
74
+
75
+
76
+ def _spawn_extend(target_root: Path, ext: str) -> Path:
77
+ return target_root / "spawn" / ".extend" / ext
78
+
79
+
80
+ def _load_installed_config(target_root: Path, ext: str) -> ExtensionConfig:
81
+ raw = load_yaml(_spawn_extend(target_root, ext) / "config.yaml")
82
+ if not raw:
83
+ raise SpawnError(f"missing extension config for {ext!r}")
84
+ return ExtensionConfig.model_validate(raw)
85
+
86
+
87
+ def _parse_frontmatter(body: str) -> tuple[dict[str, Any], str]:
88
+ lines = body.splitlines(True)
89
+ if not lines or lines[0].strip() != "---":
90
+ return {}, body
91
+ end: int | None = None
92
+ for i in range(1, len(lines)):
93
+ if lines[i].strip() == "---":
94
+ end = i
95
+ break
96
+ if end is None:
97
+ return {}, body
98
+ fm_yaml = "".join(lines[1:end])
99
+ meta = SAFE_YAML.load(fm_yaml)
100
+ meta_dict = meta if isinstance(meta, dict) else {}
101
+ remainder = "".join(lines[end + 1 :])
102
+ return meta_dict, remainder
103
+
104
+
105
+ def _candidate_norm_skill_names(extsrc_dir: Path) -> list[str]:
106
+ raw_cfg = load_yaml(extsrc_dir / "config.yaml")
107
+ if not raw_cfg:
108
+ return []
109
+ cfg = ExtensionConfig.model_validate(raw_cfg)
110
+ skills_dir = extsrc_dir / "skills"
111
+ if not skills_dir.is_dir():
112
+ return []
113
+ norms: list[str] = []
114
+ for skill_path in sorted(p for p in skills_dir.glob("*.md") if p.is_file()):
115
+ key = skill_path.name
116
+ sk = cfg.skills.get(key)
117
+ text = skill_path.read_text(encoding="utf-8")
118
+ fm, _ = _parse_frontmatter(text)
119
+ fm_name = fm.get("name")
120
+ if sk and sk.name:
121
+ name = sk.name
122
+ elif fm_name is not None:
123
+ name = str(fm_name)
124
+ else:
125
+ name = key.replace(".md", "")
126
+ norms.append(ll.normalize_skill_name(name))
127
+ return norms
128
+
129
+
130
+ def _candidate_mcp_server_names(extsrc_dir: Path) -> list[str]:
131
+ path = extsrc_dir / "mcp.json"
132
+ if not path.is_file():
133
+ return []
134
+ data = load_json(path)
135
+ names: list[str] = []
136
+ for s in data.get("servers") or []:
137
+ if isinstance(s, dict) and s.get("name"):
138
+ names.append(str(s["name"]))
139
+ return names
140
+
141
+
142
+ def _validate_render_identity_for_new_extension(target_root: Path, new_name: str, extsrc_dir: Path) -> None:
143
+ skill_claims: dict[str, list[str]] = defaultdict(list)
144
+ mcp_claims: dict[str, list[str]] = defaultdict(list)
145
+ for ext in ll.list_extensions(target_root):
146
+ if ext == new_name:
147
+ continue
148
+ for meta in ll.generate_skills_metadata(target_root, ext):
149
+ skill_claims[ll.normalize_skill_name(meta.name)].append(ext)
150
+ for srv in ll.list_mcp(target_root, ext).servers:
151
+ mcp_claims[srv.name].append(ext)
152
+ for nk in _candidate_norm_skill_names(extsrc_dir):
153
+ skill_claims[nk].append(new_name)
154
+ for nm in _candidate_mcp_server_names(extsrc_dir):
155
+ mcp_claims[nm].append(new_name)
156
+ for key, holders in skill_claims.items():
157
+ if len(set(holders)) > 1:
158
+ raise SpawnError(
159
+ f"duplicate normalized skill names across extensions ({key!r}): extensions {sorted(set(holders))}"
160
+ )
161
+ for srv_name, holders in mcp_claims.items():
162
+ if len(set(holders)) > 1:
163
+ raise SpawnError(
164
+ f"duplicate MCP server names across extensions ({srv_name!r}): extensions {sorted(set(holders))}"
165
+ )
166
+
167
+
168
+ def _check_path_conflicts(target_root: Path, candidate_config: ExtensionConfig, candidate_ext: str) -> None:
169
+ for installed_ext in ll.list_extensions(target_root):
170
+ if installed_ext == candidate_ext:
171
+ continue
172
+ installed_config = _load_installed_config(target_root, installed_ext)
173
+ for file_path in candidate_config.files:
174
+ if file_path in installed_config.files:
175
+ raise SpawnError(f"File conflict: {file_path} claimed by {installed_ext}")
176
+ for folder in candidate_config.folders:
177
+ if folder in installed_config.folders:
178
+ raise SpawnError(f"Folder conflict: {folder} claimed by {installed_ext}")
179
+
180
+
181
+ def _source_info_key(info: SourceYaml.SourceInfo) -> tuple[str, str, str | None]:
182
+ return (info.type, info.path, info.branch)
183
+
184
+
185
+ def _load_stored_source(target_root: Path, ext_name: str) -> SourceYaml | None:
186
+ p = _spawn_extend(target_root, ext_name) / "source.yaml"
187
+ if not p.is_file():
188
+ return None
189
+ raw = load_yaml(p)
190
+ if not raw:
191
+ return None
192
+ return SourceYaml.model_validate(raw)
193
+
194
+
195
+ def _check_install_version_and_source(
196
+ target_root: Path,
197
+ ext_name: str,
198
+ candidate_cfg: ExtensionConfig,
199
+ candidate_source: SourceYaml.SourceInfo,
200
+ ) -> None:
201
+ stored = _load_stored_source(target_root, ext_name)
202
+ if not stored:
203
+ return
204
+ if _source_info_key(candidate_source) != _source_info_key(stored.source):
205
+ raise SpawnError(
206
+ "source identity does not match installed record; remove the extension then install with the new source"
207
+ )
208
+ if compare_version_strings(candidate_cfg.version, stored.installed.version) <= 0:
209
+ raise SpawnError(
210
+ f"extension version must be newer than installed {stored.installed.version!r} (got {candidate_cfg.version!r})"
211
+ )
212
+
213
+
214
+ def _write_source_yaml(
215
+ target_root: Path,
216
+ ext_name: str,
217
+ source: SourceYaml.SourceInfo,
218
+ installed_version: str,
219
+ ) -> None:
220
+ path = _spawn_extend(target_root, ext_name) / "source.yaml"
221
+ rec = SourceYaml(
222
+ extension=ext_name,
223
+ source=source,
224
+ installed=SourceYaml.InstalledInfo(
225
+ version=installed_version,
226
+ installedAt=datetime.now(timezone.utc).replace(microsecond=0).isoformat(),
227
+ ),
228
+ )
229
+ save_yaml(path, rec.model_dump(by_alias=True, exclude_none=True))
230
+
231
+
232
+ def _find_extsrc(local_root: Path) -> Path:
233
+ cand = local_root / "extsrc"
234
+ if (cand / "config.yaml").is_file():
235
+ return cand
236
+ if local_root.name == "extsrc" and (local_root / "config.yaml").is_file():
237
+ return local_root
238
+ raise SpawnError(f"no extsrc/config.yaml under {local_root}")
239
+
240
+
241
+ def _ensure_git() -> None:
242
+ try:
243
+ subprocess.run(["git", "--version"], capture_output=True, check=True)
244
+ except (FileNotFoundError, subprocess.CalledProcessError) as e:
245
+ raise SpawnError(f"git is required for remote extension sources. {GIT_INSTALL_HINT}") from e
246
+
247
+
248
+ def _git_clone(url: str, branch: str | None, dest: Path) -> None:
249
+ _ensure_git()
250
+ cmd = ["git", "clone", "--depth", "1"]
251
+ if branch:
252
+ cmd += ["--branch", branch]
253
+ cmd += [url, str(dest)]
254
+ proc = subprocess.run(cmd, capture_output=True, text=True, check=False)
255
+ if proc.returncode != 0:
256
+ raise SpawnError(f"git clone failed: {(proc.stderr or proc.stdout).strip()}")
257
+
258
+
259
+ def _extract_zip(zpath: Path, dest: Path) -> None:
260
+ dest_resolved = dest.resolve()
261
+ with zipfile.ZipFile(zpath, "r") as zf:
262
+ for member in zf.infolist():
263
+ if member.filename.startswith(("/", "\\")) or ".." in Path(member.filename).parts:
264
+ raise SpawnError("zip entry escapes staging directory (path traversal)")
265
+ out = (dest / member.filename).resolve()
266
+ try:
267
+ out.relative_to(dest_resolved)
268
+ except ValueError:
269
+ raise SpawnError("zip entry escapes staging directory (path traversal)") from None
270
+ out.parent.mkdir(parents=True, exist_ok=True)
271
+ if member.is_dir():
272
+ continue
273
+ with zf.open(member, "r") as src, open(out, "wb") as dst:
274
+ shutil.copyfileobj(src, dst)
275
+
276
+
277
+ def _download_zip(url: str, dest_zip: Path) -> None:
278
+ with httpx.Client(follow_redirects=True, timeout=120.0) as client:
279
+ r = client.get(url)
280
+ r.raise_for_status()
281
+ dest_zip.write_bytes(r.content)
282
+
283
+
284
+ def _resolve_local_spec(path_str: str) -> Path:
285
+ p = Path(path_str).expanduser()
286
+ if not p.exists():
287
+ raise SpawnError(f"local path does not exist: {path_str!r}")
288
+ return p.resolve()
289
+
290
+
291
+ def stage_repository_root(path: str, branch: str | None, temp_base: Path) -> Path:
292
+ """Return the root of a cloned or extracted repository (for build manifests or target repos)."""
293
+ p = urlparse(path)
294
+ if p.scheme in ("http", "https"):
295
+ if path.lower().endswith(".zip"):
296
+ ensure_dir(temp_base)
297
+ zf = temp_base / "repo.zip"
298
+ _download_zip(path, zf)
299
+ out = temp_base / "zip_out"
300
+ ensure_dir(out)
301
+ _extract_zip(zf, out)
302
+ subs = sorted(d for d in out.iterdir() if d.is_dir())
303
+ return subs[0] if len(subs) == 1 else out
304
+ _ensure_git()
305
+ cl = temp_base / "git_out"
306
+ _git_clone(path, branch, cl)
307
+ subs = sorted(d for d in cl.iterdir() if d.is_dir())
308
+ if len(subs) == 1 and not (cl / "spawn").is_dir():
309
+ return subs[0]
310
+ return cl
311
+ local = _resolve_local_spec(path)
312
+ if local.is_file() and local.suffix.lower() == ".zip":
313
+ ensure_dir(temp_base)
314
+ out = temp_base / "local_zip"
315
+ ensure_dir(out)
316
+ _extract_zip(local, out)
317
+ subs = sorted(d for d in out.iterdir() if d.is_dir())
318
+ return subs[0] if len(subs) == 1 else out
319
+ return local
320
+
321
+
322
+ def _find_build_manifest(root: Path) -> Path:
323
+ mf = root / "extensions.yaml"
324
+ if mf.is_file():
325
+ return mf
326
+ subs = sorted(d for d in root.iterdir() if d.is_dir())
327
+ if len(subs) == 1:
328
+ nested = subs[0] / "extensions.yaml"
329
+ if nested.is_file():
330
+ return nested
331
+ raise SpawnError("extensions.yaml not found in build source")
332
+
333
+
334
+ def _materialize_files(target_root: Path, extension: str, config: ExtensionConfig) -> None:
335
+ ext_root = _spawn_extend(target_root, extension)
336
+ files_src = ext_root / "files"
337
+ for rel_path, ent in config.files.items():
338
+ dest = safe_path(target_root, rel_path.replace("\\", "/"))
339
+ src = files_src.joinpath(*Path(rel_path).parts)
340
+ if ent.mode == FileMode.static:
341
+ if not src.is_file():
342
+ continue
343
+ if dest.exists() and dest.is_file():
344
+ warnings.warn(
345
+ f"Replacing existing file from extension (static): {rel_path}",
346
+ SpawnWarning,
347
+ )
348
+ ensure_dir(dest.parent)
349
+ shutil.copy2(src, dest)
350
+ else:
351
+ if not src.is_file():
352
+ continue
353
+ if not dest.exists():
354
+ ensure_dir(dest.parent)
355
+ shutil.copy2(src, dest)
356
+
357
+
358
+ def _copy_extsrc_tree(extsrc_dir: Path, dest: Path) -> None:
359
+ if dest.exists():
360
+ shutil.rmtree(dest)
361
+ shutil.copytree(extsrc_dir, dest, dirs_exist_ok=False)
362
+
363
+
364
+ def _stage_from_path_string(
365
+ target_root: Path,
366
+ path: str,
367
+ branch: str | None,
368
+ temp_base: Path,
369
+ ) -> tuple[Path, SourceYaml.SourceInfo]:
370
+ del target_root
371
+ p = urlparse(path)
372
+ if p.scheme in ("http", "https"):
373
+ if path.lower().endswith(".zip"):
374
+ ensure_dir(temp_base)
375
+ zf = temp_base / "src.zip"
376
+ _download_zip(path, zf)
377
+ extract_to = temp_base / "zip_out"
378
+ ensure_dir(extract_to)
379
+ _extract_zip(zf, extract_to)
380
+ extsrc_root = extract_to
381
+ for child in sorted(extract_to.iterdir(), key=lambda x: x.name):
382
+ if child.is_dir() and (child / "extsrc").is_dir():
383
+ extsrc_root = child
384
+ break
385
+ extdir = _find_extsrc(extsrc_root if (extsrc_root / "extsrc").is_dir() else extsrc_root)
386
+ return extdir, SourceYaml.SourceInfo(type="zip", path=path, branch=None, resolved=None)
387
+ _ensure_git()
388
+ clone_dir = temp_base / "git"
389
+ _git_clone(path, branch, clone_dir)
390
+ root = clone_dir
391
+ if (root / "extsrc" / "config.yaml").is_file():
392
+ pass
393
+ else:
394
+ subdirs = [d for d in root.iterdir() if d.is_dir()]
395
+ if subdirs and (subdirs[0] / "extsrc" / "config.yaml").is_file():
396
+ root = subdirs[0]
397
+ extdir = _find_extsrc(root)
398
+ return extdir, SourceYaml.SourceInfo(type="git", path=path, branch=branch, resolved=None)
399
+ local = _resolve_local_spec(path)
400
+ if local.is_file() and local.suffix.lower() == ".zip":
401
+ ensure_dir(temp_base)
402
+ extract_to = temp_base / "local_zip"
403
+ ensure_dir(extract_to)
404
+ _extract_zip(local, extract_to)
405
+ extdir = _find_extsrc(extract_to)
406
+ return extdir, SourceYaml.SourceInfo(type="zip", path=str(local), branch=None, resolved=None)
407
+ extdir = _find_extsrc(local)
408
+ return extdir, SourceYaml.SourceInfo(type="local", path=str(local), branch=branch, resolved=None)
409
+
410
+
411
+ @dataclass
412
+ class StagedExtension:
413
+ extsrc_dir: Path
414
+ source_info: SourceYaml.SourceInfo
415
+ config: ExtensionConfig
416
+ temp_base: Path
417
+
418
+ def cleanup(self) -> None:
419
+ if self.temp_base.exists():
420
+ shutil.rmtree(self.temp_base, ignore_errors=True)
421
+
422
+
423
+ def _stage_extension(
424
+ target_root: Path | None,
425
+ path: str,
426
+ branch: str | None,
427
+ *,
428
+ require_init: bool,
429
+ ) -> StagedExtension:
430
+ if require_init:
431
+ if target_root is None:
432
+ raise SpawnError("target_root is required when require_init is True")
433
+ _require_init(target_root)
434
+ op = str(uuid.uuid4())
435
+ if target_root is not None:
436
+ parent = target_root / "spawn" / ".metadata" / "temp"
437
+ else:
438
+ parent = Path(tempfile.gettempdir()) / "spawn-stage"
439
+ temp_base = parent / op
440
+ ensure_dir(temp_base.parent)
441
+ ll.prune_metadata_temp(
442
+ temp_base.parent,
443
+ max_age_seconds=ll.METADATA_TEMP_MAX_AGE_SECONDS,
444
+ reserved=op,
445
+ )
446
+ try:
447
+ anchor = target_root if target_root is not None else Path.cwd()
448
+ extsrc_dir, source_info = _stage_from_path_string(anchor, path, branch, temp_base)
449
+ raw = load_yaml(extsrc_dir / "config.yaml")
450
+ if not raw:
451
+ raise SpawnError("extsrc/config.yaml missing or empty")
452
+ cfg = ExtensionConfig.model_validate(raw)
453
+ return StagedExtension(extsrc_dir=extsrc_dir, source_info=source_info, config=cfg, temp_base=temp_base)
454
+ except BaseException:
455
+ if temp_base.exists():
456
+ shutil.rmtree(temp_base, ignore_errors=True)
457
+ raise
458
+
459
+
460
+ def download_extension(target_root: Path, path: str, branch: str | None = None) -> str:
461
+ """Stage, validate, copy extension into spawn/.extend/{name}, write source.yaml. Returns extension name."""
462
+ staged = _stage_extension(target_root, path, branch, require_init=True)
463
+ try:
464
+ candidate_cfg = staged.config
465
+ ext_name = candidate_cfg.name or "extension"
466
+ _check_install_version_and_source(target_root, ext_name, candidate_cfg, staged.source_info)
467
+ _check_path_conflicts(target_root, candidate_cfg, ext_name)
468
+ _validate_render_identity_for_new_extension(target_root, ext_name, staged.extsrc_dir)
469
+ scripts.run_before_install_scripts(target_root, ext_name, ext_layout=staged.extsrc_dir)
470
+ dest = _spawn_extend(target_root, ext_name)
471
+ ensure_dir(dest.parent)
472
+ _copy_extsrc_tree(staged.extsrc_dir, dest)
473
+ _write_source_yaml(target_root, ext_name, staged.source_info, candidate_cfg.version)
474
+ _materialize_files(target_root, ext_name, candidate_cfg)
475
+ return ext_name
476
+ finally:
477
+ staged.cleanup()
478
+
479
+
480
+ def install_extension(target_root: Path, path: str, branch: str | None = None) -> None:
481
+ from spawn_cli.core import high_level as hl
482
+
483
+ hl.install_extension(target_root, path, branch)
484
+
485
+
486
+ def install_build(target_root: Path, path: str, branch: str | None = None) -> None:
487
+ for ent in list_build_extensions(path, branch):
488
+ p_ent = ent.get("path")
489
+ if not p_ent:
490
+ continue
491
+ br = ent.get("branch")
492
+ install_extension(target_root, str(p_ent), br if br is not None else branch)
493
+
494
+
495
+ def list_build_extensions(path: str, branch: str | None = None) -> list[dict]:
496
+ root = Path(path).expanduser().resolve()
497
+ if root.is_dir() and (root / "extensions.yaml").is_file():
498
+ raw = load_yaml(root / "extensions.yaml")
499
+ meta = ExtensionsMeta.model_validate(raw)
500
+ return [e.model_dump() for e in meta.extensions]
501
+
502
+ td = Path(tempfile.mkdtemp(prefix="spawn-build-list-"))
503
+ try:
504
+ work = td / "stage"
505
+ repo_root = stage_repository_root(str(path), branch, work)
506
+ mf = _find_build_manifest(repo_root)
507
+ raw = load_yaml(mf)
508
+ meta = ExtensionsMeta.model_validate(raw)
509
+ return [e.model_dump() for e in meta.extensions]
510
+ finally:
511
+ shutil.rmtree(td, ignore_errors=True)
512
+
513
+
514
+ __all__ = [
515
+ "StagedExtension",
516
+ "compare_version_strings",
517
+ "download_extension",
518
+ "install_build",
519
+ "install_extension",
520
+ "list_build_extensions",
521
+ "stage_repository_root",
522
+ "_check_install_version_and_source",
523
+ "_copy_extsrc_tree",
524
+ "_find_build_manifest",
525
+ "_load_stored_source",
526
+ "_materialize_files",
527
+ "_spawn_extend",
528
+ "_source_info_key",
529
+ "_stage_extension",
530
+ "_validate_render_identity_for_new_extension",
531
+ "_write_source_yaml",
532
+ ]
@@ -0,0 +1,5 @@
1
+ from __future__ import annotations
2
+
3
+ from spawn_cli.errors import SpawnError, SpawnWarning
4
+
5
+ __all__ = ["SpawnError", "SpawnWarning"]