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,462 @@
1
+ from __future__ import annotations
2
+
3
+ import shutil
4
+ import tempfile
5
+ import warnings
6
+ from pathlib import Path
7
+
8
+ from spawn_cli.core import download as dl
9
+ from spawn_cli.core import low_level as ll
10
+ from spawn_cli.core import scripts
11
+ from spawn_cli.core.errors import SpawnError, SpawnWarning
12
+ from spawn_cli.ide.registry import get as ide_get
13
+ from spawn_cli.io.json_io import load_json
14
+ from spawn_cli.io.paths import ensure_dir, safe_path
15
+ from spawn_cli.io.yaml_io import load_yaml, save_yaml
16
+ from spawn_cli.models.config import ExtensionConfig, ReadFlag
17
+
18
+ SPAWN_ENTRY_POINT_PROMPT = """\
19
+ Before working, read `spawn/navigation.yaml`.
20
+ Read every file listed under `read-required`.
21
+ Inspect `read-contextual` descriptions and read only files relevant to the current task.
22
+ """
23
+
24
+
25
+ def _require_init(target_root: Path) -> None:
26
+ if not (target_root / "spawn").is_dir():
27
+ raise SpawnError("need init before running this command")
28
+
29
+
30
+ def _extend_dir(target_root: Path, extension: str) -> Path:
31
+ return target_root / "spawn" / ".extend" / extension
32
+
33
+
34
+ def _agent_ignore_merge_excluding(target_root: Path, skip_ext: str) -> list[str]:
35
+ merged: list[str] = []
36
+ seen: set[str] = set()
37
+ for g in ll.get_core_agent_ignore(target_root):
38
+ if g not in seen:
39
+ seen.add(g)
40
+ merged.append(g)
41
+ for ext in ll.list_extensions(target_root):
42
+ if ext == skip_ext:
43
+ continue
44
+ for g in ll.get_ext_agent_ignore(target_root, ext):
45
+ if g not in seen:
46
+ seen.add(g)
47
+ merged.append(g)
48
+ return merged
49
+
50
+
51
+ def refresh_gitignore(target_root: Path) -> None:
52
+ _require_init(target_root)
53
+ new_items: list[str] = []
54
+ seen: set[str] = set()
55
+ for ext in ll.list_extensions(target_root):
56
+ for g in ll.get_ext_git_ignore(target_root, ext):
57
+ if g not in seen:
58
+ seen.add(g)
59
+ new_items.append(g)
60
+ existing = ll.get_git_ignore_list(target_root)
61
+ ll.save_git_ignore_list(target_root, new_items)
62
+ new_set, ex_set = set(new_items), set(existing)
63
+ ll.push_to_global_gitignore(target_root, sorted(new_set - ex_set))
64
+ ll.remove_from_global_gitignore(target_root, sorted(ex_set - new_set))
65
+
66
+
67
+ def refresh_agent_ignore(target_root: Path, ide: str) -> None:
68
+ _require_init(target_root)
69
+ old = ll.get_agent_ignore_list(target_root, ide)
70
+ new = ll.get_all_agent_ignore(target_root)
71
+ adapter = ide_get(ide)
72
+ adapter.remove_agent_ignore(target_root, sorted(set(old) - set(new)))
73
+ adapter.add_agent_ignore(target_root, sorted(set(new) - set(old)))
74
+ ll.save_agent_ignore_list(target_root, ide, new)
75
+
76
+
77
+ def refresh_skills(target_root: Path, ide: str, extension: str) -> None:
78
+ _require_init(target_root)
79
+ adapter = ide_get(ide)
80
+ prior = ll.get_rendered_skills(target_root, ide, extension)
81
+ ll.validate_rendered_identity(target_root)
82
+ adapter.remove_skills(target_root, prior)
83
+ metas = ll.generate_skills_metadata(target_root, extension)
84
+ rendered = adapter.add_skills(target_root, metas)
85
+ ll.save_skills_rendered(target_root, ide, extension, rendered)
86
+
87
+
88
+ def remove_skills(target_root: Path, ide: str, extension: str) -> None:
89
+ _require_init(target_root)
90
+ prior = ll.get_rendered_skills(target_root, ide, extension)
91
+ ide_get(ide).remove_skills(target_root, prior)
92
+ ll.save_skills_rendered(target_root, ide, extension, [])
93
+
94
+
95
+ def refresh_mcp(target_root: Path, ide: str, extension: str) -> None:
96
+ _require_init(target_root)
97
+ prior = ll.get_rendered_mcp(target_root, ide, extension)
98
+ ll.validate_rendered_identity(target_root)
99
+ nm = ll.list_mcp(target_root, extension)
100
+ adapter = ide_get(ide)
101
+ adapter.remove_mcp(target_root, prior)
102
+ new_names = adapter.add_mcp(target_root, nm)
103
+ ll.save_mcp_rendered(target_root, ide, extension, new_names)
104
+
105
+
106
+ def remove_mcp(target_root: Path, ide: str, extension: str) -> None:
107
+ _require_init(target_root)
108
+ names = ll.get_rendered_mcp(target_root, ide, extension)
109
+ ide_get(ide).remove_mcp(target_root, names)
110
+ ll.save_mcp_rendered(target_root, ide, extension, [])
111
+
112
+
113
+ def refresh_entry_point(target_root: Path, ide: str) -> None:
114
+ _require_init(target_root)
115
+ ide_get(ide).rewrite_entry_point(target_root, SPAWN_ENTRY_POINT_PROMPT)
116
+
117
+
118
+ def refresh_extension_for_ide(target_root: Path, ide: str, extension: str) -> None:
119
+ refresh_mcp(target_root, ide, extension)
120
+ refresh_skills(target_root, ide, extension)
121
+ refresh_agent_ignore(target_root, ide)
122
+
123
+
124
+ def remove_extension_for_ide(target_root: Path, ide: str, extension: str) -> None:
125
+ remove_mcp(target_root, ide, extension)
126
+ remove_skills(target_root, ide, extension)
127
+ old = ll.get_agent_ignore_list(target_root, ide)
128
+ new = _agent_ignore_merge_excluding(target_root, extension)
129
+ adapter = ide_get(ide)
130
+ adapter.remove_agent_ignore(target_root, sorted(set(old) - set(new)))
131
+ adapter.add_agent_ignore(target_root, sorted(set(new) - set(old)))
132
+ ll.save_agent_ignore_list(target_root, ide, new)
133
+
134
+
135
+ def refresh_navigation(target_root: Path) -> None:
136
+ _require_init(target_root)
137
+ for ext in ll.list_extensions(target_root):
138
+ ll.save_extension_navigation(
139
+ target_root,
140
+ ext,
141
+ ll.get_required_read_global(target_root, ext),
142
+ ll.get_auto_read_global(target_root, ext),
143
+ )
144
+ ll.save_rules_navigation(target_root)
145
+
146
+
147
+ def refresh_rules_navigation(target_root: Path) -> None:
148
+ _require_init(target_root)
149
+ ll.save_rules_navigation(target_root)
150
+
151
+
152
+ def _refresh_extension_core(target_root: Path, extension: str) -> None:
153
+ for ide in ll.list_ides(target_root):
154
+ refresh_mcp(target_root, ide, extension)
155
+ refresh_skills(target_root, ide, extension)
156
+ for ide in ll.list_ides(target_root):
157
+ refresh_agent_ignore(target_root, ide)
158
+ refresh_gitignore(target_root)
159
+ refresh_navigation(target_root)
160
+ for ide in ll.list_ides(target_root):
161
+ refresh_entry_point(target_root, ide)
162
+
163
+
164
+ def refresh_extension(target_root: Path, extension: str) -> None:
165
+ _require_init(target_root)
166
+ scripts.run_before_install_scripts(target_root, extension)
167
+ _refresh_extension_core(target_root, extension)
168
+ scripts.run_after_install_scripts(target_root, extension)
169
+
170
+
171
+ def remove_extension(target_root: Path, extension: str) -> None:
172
+ _require_init(target_root)
173
+ if extension not in ll.list_extensions(target_root):
174
+ return
175
+ cfg = ll._load_ext_config(target_root, extension)
176
+ scripts.run_before_uninstall_scripts(target_root, extension)
177
+ for ide in ll.list_ides(target_root):
178
+ remove_extension_for_ide(target_root, ide, extension)
179
+ static_files, static_folders = ll.get_removable(target_root, extension)
180
+ for rel in static_files:
181
+ p = safe_path(target_root, rel.replace("\\", "/"))
182
+ if p.is_file():
183
+ p.unlink(missing_ok=True)
184
+ for rel in static_folders:
185
+ p = safe_path(target_root, rel.replace("\\", "/"))
186
+ if p.is_dir():
187
+ shutil.rmtree(p, ignore_errors=True)
188
+ ext_dir = _extend_dir(target_root, extension)
189
+ after_spec = scripts.snapshot_after_uninstall_script(ext_dir, cfg)
190
+ if ext_dir.is_dir():
191
+ shutil.rmtree(ext_dir, ignore_errors=True)
192
+ refresh_gitignore(target_root)
193
+ refresh_navigation(target_root)
194
+ for ide in ll.list_ides(target_root):
195
+ refresh_agent_ignore(target_root, ide)
196
+ refresh_entry_point(target_root, ide)
197
+ scripts.run_after_uninstall_from_snapshot(target_root, extension, after_spec)
198
+
199
+
200
+ def update_extension(target_root: Path, extension: str, *, force: bool = False) -> None:
201
+ _require_init(target_root)
202
+ stored = dl._load_stored_source(target_root, extension)
203
+ if not stored:
204
+ raise SpawnError(f"no source.yaml for extension {extension!r}")
205
+ staged = dl._stage_extension(target_root, stored.source.path, stored.source.branch, require_init=True)
206
+ try:
207
+ cand = staged.config
208
+ if dl._source_info_key(staged.source_info) != dl._source_info_key(stored.source):
209
+ raise SpawnError(
210
+ "source identity does not match installed record; remove the extension then install again"
211
+ )
212
+ if not force and dl.compare_version_strings(cand.version, stored.installed.version) <= 0:
213
+ raise SpawnError(
214
+ f"extension version must be newer than installed {stored.installed.version!r} "
215
+ f"(got {cand.version!r})"
216
+ )
217
+ dl._validate_render_identity_for_new_extension(target_root, extension, staged.extsrc_dir)
218
+ scripts.run_before_install_scripts(target_root, extension, ext_layout=staged.extsrc_dir)
219
+ dest = _extend_dir(target_root, extension)
220
+ ensure_dir(dest.parent)
221
+ dl._copy_extsrc_tree(staged.extsrc_dir, dest)
222
+ dl._write_source_yaml(target_root, extension, staged.source_info, cand.version)
223
+ dl._materialize_files(target_root, extension, cand)
224
+ finally:
225
+ staged.cleanup()
226
+ _refresh_extension_core(target_root, extension)
227
+ scripts.run_after_install_scripts(target_root, extension)
228
+
229
+
230
+ def reinstall_extension(target_root: Path, extension: str) -> None:
231
+ _require_init(target_root)
232
+ if extension not in ll.list_extensions(target_root):
233
+ raise SpawnError(f"extension {extension!r} is not installed")
234
+ stored = dl._load_stored_source(target_root, extension)
235
+ if not stored:
236
+ raise SpawnError(f"no source.yaml for extension {extension!r}")
237
+ remove_extension(target_root, extension)
238
+ install_extension(target_root, stored.source.path, stored.source.branch)
239
+
240
+
241
+ def extension_healthcheck(target_root: Path, extension: str) -> bool:
242
+ _require_init(target_root)
243
+ if extension not in ll.list_extensions(target_root):
244
+ return False
245
+ ext_root = _extend_dir(target_root, extension)
246
+ try:
247
+ extension_check(ext_root, strict=True)
248
+ except SpawnError:
249
+ return False
250
+ return scripts.run_healthcheck_scripts(target_root, extension)
251
+
252
+
253
+ def extension_init(path: Path, name: str) -> None:
254
+ extsrc = path / "extsrc"
255
+ cfg_path = extsrc / "config.yaml"
256
+ if cfg_path.is_file():
257
+ warnings.warn(
258
+ "extsrc/config.yaml already exists; left unchanged during extension init",
259
+ SpawnWarning,
260
+ )
261
+ return
262
+ ensure_dir(extsrc / "skills")
263
+ ensure_dir(extsrc / "files")
264
+ ensure_dir(extsrc / "setup")
265
+ template = {
266
+ "name": name,
267
+ "schema": 1,
268
+ "version": "0.1.0",
269
+ "files": {},
270
+ "folders": {},
271
+ "agent-ignore": [],
272
+ "git-ignore": [],
273
+ "skills": {},
274
+ "setup": {},
275
+ }
276
+ save_yaml(cfg_path, template)
277
+
278
+
279
+ def extension_check(path: Path, strict: bool = False) -> list[str]:
280
+ warnings_out: list[str] = []
281
+ if (path / "extsrc" / "config.yaml").is_file():
282
+ extsrc = path / "extsrc"
283
+ elif (path / "config.yaml").is_file():
284
+ extsrc = path
285
+ else:
286
+ raise SpawnError("extsrc/config.yaml is missing")
287
+ cfg_path = extsrc / "config.yaml"
288
+ raw = load_yaml(cfg_path)
289
+ try:
290
+ cfg = ExtensionConfig.model_validate(raw)
291
+ except Exception as e:
292
+ raise SpawnError(f"invalid extension config: {e}") from e
293
+ skills_dir = extsrc / "skills"
294
+ setup_dir = extsrc / "setup"
295
+ files_dir = extsrc / "files"
296
+ for key, sk in cfg.skills.items():
297
+ skill_file = skills_dir / key
298
+ if not skill_file.is_file():
299
+ msg = f"skill file missing: skills/{key}"
300
+ if strict:
301
+ raise SpawnError(msg)
302
+ warnings_out.append(msg)
303
+ for _ent_key, ent in cfg.files.items():
304
+ gr, lr = ent.globalRead, ent.localRead
305
+ if gr != ReadFlag.no or lr != ReadFlag.no:
306
+ if not ent.description or not ent.description.strip():
307
+ msg = f"file {_ent_key!r} has read visibility but no description"
308
+ if strict:
309
+ raise SpawnError(msg)
310
+ warnings_out.append(msg)
311
+ mcp_path = extsrc / "mcp.json"
312
+ if mcp_path.is_file():
313
+ try:
314
+ load_json(mcp_path)
315
+ except Exception as e:
316
+ msg = f"mcp.json not parseable: {e}"
317
+ if strict:
318
+ raise SpawnError(msg) from e
319
+ warnings_out.append(msg)
320
+ if cfg.setup:
321
+ for _phase, rel in (
322
+ ("before-install", cfg.setup.before_install),
323
+ ("after-install", cfg.setup.after_install),
324
+ ("before-uninstall", cfg.setup.before_uninstall),
325
+ ("after-uninstall", cfg.setup.after_uninstall),
326
+ ("healthcheck", cfg.setup.healthcheck),
327
+ ):
328
+ if rel and not (setup_dir / rel).is_file():
329
+ msg = f"setup script missing: setup/{rel}"
330
+ if strict:
331
+ raise SpawnError(msg)
332
+ warnings_out.append(msg)
333
+ declared = set(cfg.files.keys())
334
+ if files_dir.is_dir():
335
+ for f in files_dir.rglob("*"):
336
+ if f.is_file():
337
+ rel = f.relative_to(files_dir).as_posix()
338
+ if rel not in declared:
339
+ msg = f"undeclared file under extsrc/files: {rel}"
340
+ if strict:
341
+ raise SpawnError(msg)
342
+ warnings_out.append(msg)
343
+ return warnings_out
344
+
345
+
346
+ def extension_from_rules(source: str, output_path: Path, name: str, branch: str | None = None) -> None:
347
+ tmp = Path(tempfile.mkdtemp(prefix="spawn-from-rules-"))
348
+ try:
349
+ src_path = Path(source).expanduser()
350
+ if src_path.is_dir() and (src_path.resolve() / "spawn" / "rules").is_dir():
351
+ root = src_path.resolve()
352
+ else:
353
+ root = dl.stage_repository_root(str(source), branch, tmp)
354
+ rules = root / "spawn" / "rules"
355
+ extsrc = output_path / "extsrc"
356
+ ensure_dir(extsrc / "files" / "spawn" / "rules")
357
+ if rules.is_dir():
358
+ for f in rules.rglob("*"):
359
+ if f.is_file():
360
+ rel = f.relative_to(rules)
361
+ dest = extsrc / "files" / "spawn" / "rules" / rel
362
+ ensure_dir(dest.parent)
363
+ shutil.copy2(f, dest)
364
+ files_meta: dict = {}
365
+ if rules.is_dir():
366
+ for f in sorted(rules.rglob("*")):
367
+ if f.is_file():
368
+ rel_key = ("spawn/rules/" + f.relative_to(rules).as_posix()).replace("\\", "/")
369
+ files_meta[rel_key] = {
370
+ "description": "Rule imported by extension_from_rules.",
371
+ "mode": "static",
372
+ "globalRead": "required",
373
+ "localRead": "no",
374
+ }
375
+ save_yaml(
376
+ extsrc / "config.yaml",
377
+ {
378
+ "name": name,
379
+ "schema": 1,
380
+ "version": "0.1.0",
381
+ "files": files_meta,
382
+ "folders": {},
383
+ "agent-ignore": [],
384
+ "git-ignore": [],
385
+ "skills": {},
386
+ "setup": {},
387
+ },
388
+ )
389
+ ensure_dir(extsrc / "skills")
390
+ ensure_dir(extsrc / "setup")
391
+ finally:
392
+ shutil.rmtree(tmp, ignore_errors=True)
393
+
394
+
395
+ def add_ide(target_root: Path, ide: str) -> None:
396
+ _require_init(target_root)
397
+ ll.add_ide_to_list(target_root, ide)
398
+ adapter = ide_get(ide)
399
+ dr = adapter.detect(target_root)
400
+ caps = dr.capabilities
401
+ if caps.skills == "unsupported":
402
+ warnings.warn(
403
+ f"IDE {ide!r} does not support skills; skills were skipped",
404
+ SpawnWarning,
405
+ )
406
+ if caps.mcp in ("unsupported", "external"):
407
+ warnings.warn(
408
+ f"IDE {ide!r} has limited MCP support ({caps.mcp})",
409
+ SpawnWarning,
410
+ )
411
+ refresh_entry_point(target_root, ide)
412
+ for ext in ll.list_extensions(target_root):
413
+ refresh_mcp(target_root, ide, ext)
414
+ refresh_skills(target_root, ide, ext)
415
+ refresh_agent_ignore(target_root, ide)
416
+
417
+
418
+ def remove_ide(target_root: Path, ide: str) -> None:
419
+ _require_init(target_root)
420
+ for ext in ll.list_extensions(target_root):
421
+ remove_mcp(target_root, ide, ext)
422
+ remove_skills(target_root, ide, ext)
423
+ old = ll.get_agent_ignore_list(target_root, ide)
424
+ ide_get(ide).remove_agent_ignore(target_root, old)
425
+ ide_get(ide).finalize_repo_after_ide_removed(target_root)
426
+ ll.remove_ide_from_list(target_root, ide)
427
+ ll.remove_ide_metadata_dir(target_root, ide)
428
+
429
+
430
+ def install_extension(target_root: Path, path: str, branch: str | None = None) -> None:
431
+ _require_init(target_root)
432
+ name = dl.download_extension(target_root, path, branch)
433
+ _refresh_extension_core(target_root, name)
434
+ scripts.run_after_install_scripts(target_root, name)
435
+
436
+
437
+ __all__ = [
438
+ "SPAWN_ENTRY_POINT_PROMPT",
439
+ "_refresh_extension_core",
440
+ "add_ide",
441
+ "extension_check",
442
+ "extension_from_rules",
443
+ "extension_healthcheck",
444
+ "extension_init",
445
+ "install_extension",
446
+ "refresh_agent_ignore",
447
+ "refresh_entry_point",
448
+ "refresh_extension",
449
+ "refresh_extension_for_ide",
450
+ "refresh_gitignore",
451
+ "refresh_mcp",
452
+ "refresh_navigation",
453
+ "refresh_rules_navigation",
454
+ "refresh_skills",
455
+ "reinstall_extension",
456
+ "remove_extension",
457
+ "remove_extension_for_ide",
458
+ "remove_ide",
459
+ "remove_mcp",
460
+ "remove_skills",
461
+ "update_extension",
462
+ ]