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.
- spawn_cli/__init__.py +3 -0
- spawn_cli/cli.py +243 -0
- spawn_cli/core/__init__.py +5 -0
- spawn_cli/core/download.py +532 -0
- spawn_cli/core/errors.py +5 -0
- spawn_cli/core/high_level.py +462 -0
- spawn_cli/core/low_level.py +914 -0
- spawn_cli/core/scripts.py +246 -0
- spawn_cli/errors.py +9 -0
- spawn_cli/ide/__init__.py +63 -0
- spawn_cli/ide/_helpers.py +168 -0
- spawn_cli/ide/_stub.py +63 -0
- spawn_cli/ide/_vacancy.py +221 -0
- spawn_cli/ide/claude_code.py +145 -0
- spawn_cli/ide/codex.py +120 -0
- spawn_cli/ide/cursor.py +122 -0
- spawn_cli/ide/gemini_cli.py +130 -0
- spawn_cli/ide/github_copilot.py +167 -0
- spawn_cli/ide/registry.py +142 -0
- spawn_cli/ide/windsurf.py +91 -0
- spawn_cli/io/__init__.py +21 -0
- spawn_cli/io/json_io.py +21 -0
- spawn_cli/io/lock.py +24 -0
- spawn_cli/io/paths.py +23 -0
- spawn_cli/io/text_io.py +16 -0
- spawn_cli/io/toml_io.py +26 -0
- spawn_cli/io/yaml_io.py +32 -0
- spawn_cli/models/__init__.py +63 -0
- spawn_cli/models/config.py +111 -0
- spawn_cli/models/mcp.py +49 -0
- spawn_cli/models/metadata.py +28 -0
- spawn_cli/models/navigation.py +30 -0
- spawn_cli/models/skill.py +29 -0
- spawn_cli/resources/__init__.py +0 -0
- spawn_cli/resources/default_core_config.yaml +5 -0
- spawn_cli/warnings_display.py +35 -0
- spawn_cli-0.1.1.dist-info/METADATA +11 -0
- spawn_cli-0.1.1.dist-info/RECORD +41 -0
- spawn_cli-0.1.1.dist-info/WHEEL +5 -0
- spawn_cli-0.1.1.dist-info/entry_points.txt +2 -0
- spawn_cli-0.1.1.dist-info/top_level.txt +1 -0
|
@@ -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
|
+
|