xrefkit 0.3.0__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.
- xrefkit/__init__.py +5 -0
- xrefkit/__main__.py +5 -0
- xrefkit/catalog_cli.py +57 -0
- xrefkit/cli.py +71 -0
- xrefkit/contracts.py +297 -0
- xrefkit/ctx.py +160 -0
- xrefkit/dashboard.py +1220 -0
- xrefkit/discovery.py +85 -0
- xrefkit/gate.py +428 -0
- xrefkit/goalstate.py +555 -0
- xrefkit/hashing.py +18 -0
- xrefkit/import_skill.py +469 -0
- xrefkit/instance.py +133 -0
- xrefkit/loaders.py +77 -0
- xrefkit/mcp/__init__.py +26 -0
- xrefkit/mcp/audit.py +168 -0
- xrefkit/mcp/bootstrap.py +337 -0
- xrefkit/mcp/catalog.py +2638 -0
- xrefkit/mcp/cli.py +173 -0
- xrefkit/mcp/client_cache.py +356 -0
- xrefkit/mcp/context_registry.py +277 -0
- xrefkit/mcp/contracts.py +243 -0
- xrefkit/mcp/dist.py +234 -0
- xrefkit/mcp/ownership.py +246 -0
- xrefkit/mcp/repository.py +217 -0
- xrefkit/mcp/schemas.py +349 -0
- xrefkit/mcp/server.py +773 -0
- xrefkit/mcp/startup_contract_pack.py +154 -0
- xrefkit/mcp_tools.py +47 -0
- xrefkit/models/__init__.py +41 -0
- xrefkit/models/common.py +185 -0
- xrefkit/models/effective_bundle.py +131 -0
- xrefkit/models/local_manifest.py +217 -0
- xrefkit/models/package_manifest.py +126 -0
- xrefkit/models/run_log.py +276 -0
- xrefkit/models/server_config.py +160 -0
- xrefkit/models/skill_definition.py +131 -0
- xrefkit/operations_cli.py +670 -0
- xrefkit/ownership.py +276 -0
- xrefkit/packmeta.py +289 -0
- xrefkit/registry.py +334 -0
- xrefkit/resolver.py +252 -0
- xrefkit/resource_provider.py +187 -0
- xrefkit/resources/base/contracts.json +178 -0
- xrefkit/resources/base/current.json +6 -0
- xrefkit/resources/base/generations/7a682a5272907354/contracts.json +178 -0
- xrefkit/resources/base/generations/7a682a5272907354/model_body.md +22 -0
- xrefkit/resources/base/generations/9929294385ccb7b0/contracts.json +178 -0
- xrefkit/resources/base/generations/9929294385ccb7b0/model_body.md +22 -0
- xrefkit/resources/base/model_body.md +22 -0
- xrefkit/runlog.py +45 -0
- xrefkit/skillmeta.py +1034 -0
- xrefkit/skillrun.py +2381 -0
- xrefkit/structure_catalog.py +199 -0
- xrefkit/tools/__init__.py +119 -0
- xrefkit/tools/__main__.py +4 -0
- xrefkit/v2_cli.py +130 -0
- xrefkit/workspace.py +117 -0
- xrefkit/xref.py +1048 -0
- xrefkit-0.3.0.dist-info/METADATA +203 -0
- xrefkit-0.3.0.dist-info/RECORD +65 -0
- xrefkit-0.3.0.dist-info/WHEEL +5 -0
- xrefkit-0.3.0.dist-info/entry_points.txt +2 -0
- xrefkit-0.3.0.dist-info/licenses/LICENSE +21 -0
- xrefkit-0.3.0.dist-info/top_level.txt +1 -0
xrefkit/import_skill.py
ADDED
|
@@ -0,0 +1,469 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# xid: D8B4A2F7C931
|
|
3
|
+
|
|
4
|
+
"""Convert an external file-based Skill into XRefKit Skill + Knowledge files."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import argparse
|
|
9
|
+
import json
|
|
10
|
+
import re
|
|
11
|
+
import sys
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
REPO_ROOT = Path(__file__).resolve().parents[1]
|
|
17
|
+
if str(REPO_ROOT) not in sys.path:
|
|
18
|
+
sys.path.insert(0, str(REPO_ROOT))
|
|
19
|
+
|
|
20
|
+
from xrefkit.xref import _extract_xid, _gen_xid, _replace_or_insert_xid_block
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
MARKDOWN_LINK_RE = re.compile(r"(?P<prefix>!?)\[(?P<label>[^\]]*)\]\((?P<url>[^)\s]+)(?P<rest>[^)]*)\)")
|
|
24
|
+
KNOWLEDGE_SUFFIXES = {".md", ".mdx", ".txt"}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class ImportedKnowledge:
|
|
29
|
+
source_path: str
|
|
30
|
+
target_path: str
|
|
31
|
+
xid: str
|
|
32
|
+
title: str
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True)
|
|
36
|
+
class ConversionResult:
|
|
37
|
+
ok: bool
|
|
38
|
+
skill_id: str
|
|
39
|
+
skill_dir: str
|
|
40
|
+
skill_doc: str
|
|
41
|
+
meta_doc: str
|
|
42
|
+
imported_knowledge: list[ImportedKnowledge]
|
|
43
|
+
changed_files: list[str]
|
|
44
|
+
warnings: list[str]
|
|
45
|
+
dry_run: bool
|
|
46
|
+
|
|
47
|
+
def to_dict(self) -> dict[str, object]:
|
|
48
|
+
return {
|
|
49
|
+
"ok": self.ok,
|
|
50
|
+
"skill_id": self.skill_id,
|
|
51
|
+
"skill_dir": self.skill_dir,
|
|
52
|
+
"skill_doc": self.skill_doc,
|
|
53
|
+
"meta_doc": self.meta_doc,
|
|
54
|
+
"imported_knowledge": [item.__dict__ for item in self.imported_knowledge],
|
|
55
|
+
"changed_files": self.changed_files,
|
|
56
|
+
"warnings": self.warnings,
|
|
57
|
+
"dry_run": self.dry_run,
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass(frozen=True)
|
|
62
|
+
class BatchConversionResult:
|
|
63
|
+
ok: bool
|
|
64
|
+
source_root: str
|
|
65
|
+
converted_skills: list[ConversionResult]
|
|
66
|
+
changed_files: list[str]
|
|
67
|
+
warnings: list[str]
|
|
68
|
+
dry_run: bool
|
|
69
|
+
|
|
70
|
+
def to_dict(self) -> dict[str, object]:
|
|
71
|
+
return {
|
|
72
|
+
"ok": self.ok,
|
|
73
|
+
"source_root": self.source_root,
|
|
74
|
+
"converted_skills": [item.to_dict() for item in self.converted_skills],
|
|
75
|
+
"changed_files": self.changed_files,
|
|
76
|
+
"warnings": self.warnings,
|
|
77
|
+
"dry_run": self.dry_run,
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _read_text(path: Path) -> str:
|
|
82
|
+
return path.read_text(encoding="utf-8")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _write_text(path: Path, text: str, *, dry_run: bool) -> bool:
|
|
86
|
+
if path.exists() and path.read_text(encoding="utf-8") == text:
|
|
87
|
+
return False
|
|
88
|
+
if not dry_run:
|
|
89
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
90
|
+
path.write_text(text, encoding="utf-8", newline="\n")
|
|
91
|
+
return True
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _slug(value: str) -> str:
|
|
95
|
+
slug = re.sub(r"[^A-Za-z0-9_.-]+", "_", value.strip()).strip("._-")
|
|
96
|
+
return slug.lower() or "imported_skill"
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _title_from_text(text: str, fallback: str) -> str:
|
|
100
|
+
for line in text.splitlines():
|
|
101
|
+
if line.startswith("# "):
|
|
102
|
+
return line[2:].strip() or fallback
|
|
103
|
+
return fallback
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _ensure_markdown_xid(text: str, known_xids: set[str]) -> tuple[str, str]:
|
|
107
|
+
xid = _extract_xid(text)
|
|
108
|
+
if xid and re.fullmatch(r"[A-F0-9]{12}", xid):
|
|
109
|
+
known_xids.add(xid)
|
|
110
|
+
return text, xid
|
|
111
|
+
xid = _gen_xid()
|
|
112
|
+
while xid in known_xids:
|
|
113
|
+
xid = _gen_xid()
|
|
114
|
+
known_xids.add(xid)
|
|
115
|
+
return _replace_or_insert_xid_block(text, xid), xid
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _repo_rel(path: Path, root: Path) -> str:
|
|
119
|
+
try:
|
|
120
|
+
return path.relative_to(root).as_posix()
|
|
121
|
+
except ValueError:
|
|
122
|
+
return path.as_posix()
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _relative_markdown_url(from_path: Path, to_path: Path) -> str:
|
|
126
|
+
return Path(__import__("os").path.relpath(to_path, start=from_path.parent)).as_posix()
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _find_skill_doc(source_dir: Path, explicit: str | None) -> Path:
|
|
130
|
+
if explicit:
|
|
131
|
+
path = source_dir / explicit
|
|
132
|
+
if not path.is_file():
|
|
133
|
+
raise FileNotFoundError(f"skill doc not found: {path}")
|
|
134
|
+
return path
|
|
135
|
+
for name in ("SKILL.md", "skill.md", "README.md", "readme.md"):
|
|
136
|
+
path = source_dir / name
|
|
137
|
+
if path.is_file():
|
|
138
|
+
return path
|
|
139
|
+
raise FileNotFoundError("could not find SKILL.md or README.md in source skill directory")
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _resolve_source_link(source_doc: Path, url: str, source_root: Path) -> Path | None:
|
|
143
|
+
if "://" in url or url.startswith("#"):
|
|
144
|
+
return None
|
|
145
|
+
path_part = url.split("#", 1)[0]
|
|
146
|
+
if not path_part:
|
|
147
|
+
return None
|
|
148
|
+
candidate = (source_doc.parent / path_part).resolve()
|
|
149
|
+
try:
|
|
150
|
+
candidate.relative_to(source_root)
|
|
151
|
+
except ValueError:
|
|
152
|
+
return None
|
|
153
|
+
if not candidate.is_file():
|
|
154
|
+
return None
|
|
155
|
+
if candidate.suffix.lower() not in KNOWLEDGE_SUFFIXES:
|
|
156
|
+
return None
|
|
157
|
+
return candidate
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _knowledge_target_path(source_path: Path, source_root: Path, knowledge_dir: Path) -> Path:
|
|
161
|
+
rel = source_path.relative_to(source_root)
|
|
162
|
+
parts = rel.parts
|
|
163
|
+
if len(parts) > 1 and parts[0].lower() == "knowledge":
|
|
164
|
+
rel = Path(*parts[1:])
|
|
165
|
+
suffix = ".md" if source_path.suffix.lower() == ".txt" else source_path.suffix
|
|
166
|
+
return knowledge_dir / rel.with_suffix(suffix)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _knowledge_text(source_path: Path, source_root: Path, skill_id: str, known_xids: set[str]) -> tuple[str, str, str]:
|
|
170
|
+
text = _read_text(source_path)
|
|
171
|
+
title = _title_from_text(text, source_path.stem.replace("_", " ").replace("-", " ").title())
|
|
172
|
+
if source_path.suffix.lower() == ".txt":
|
|
173
|
+
rel = source_path.relative_to(source_root).as_posix()
|
|
174
|
+
text = f"# {title}\n\n{text.rstrip()}\n\n## Source\n\n- imported_from: `{rel}`\n"
|
|
175
|
+
elif "## Source" not in text:
|
|
176
|
+
rel = source_path.relative_to(source_root).as_posix()
|
|
177
|
+
text = text.rstrip() + f"\n\n## Source\n\n- imported_from: `{rel}`\n"
|
|
178
|
+
text, xid = _ensure_markdown_xid(text, known_xids)
|
|
179
|
+
if f"- imported_by_skill: `{skill_id}`" not in text:
|
|
180
|
+
text = text.rstrip() + f"\n- imported_by_skill: `{skill_id}`\n"
|
|
181
|
+
return text, xid, title
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _existing_knowledge_text(target_path: Path, known_xids: set[str]) -> tuple[str, str, str] | None:
|
|
185
|
+
if not target_path.exists():
|
|
186
|
+
return None
|
|
187
|
+
text = _read_text(target_path)
|
|
188
|
+
xid = _extract_xid(text)
|
|
189
|
+
if not xid or not re.fullmatch(r"[A-F0-9]{12}", xid):
|
|
190
|
+
return None
|
|
191
|
+
known_xids.add(xid)
|
|
192
|
+
title = _title_from_text(text, target_path.stem.replace("_", " ").replace("-", " ").title())
|
|
193
|
+
return text, xid, title
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _meta_text(*, skill_id: str, skill_doc_xid: str, knowledge: list[ImportedKnowledge], known_xids: set[str]) -> tuple[str, str]:
|
|
197
|
+
lines = [
|
|
198
|
+
f"# Skill Meta: {skill_id}",
|
|
199
|
+
"",
|
|
200
|
+
f"- skill_id: `{skill_id}`",
|
|
201
|
+
"- maturity: `draft`",
|
|
202
|
+
"- skill_doc: `./SKILL.md`",
|
|
203
|
+
"- summary: imported external Skill normalized into XRefKit split form",
|
|
204
|
+
"- use_when: imported Skill behavior is selected by explicit human routing or later catalog registration",
|
|
205
|
+
"- input: external Skill inputs as described in SKILL.md",
|
|
206
|
+
"- output: external Skill outputs as described in SKILL.md",
|
|
207
|
+
"- capability_layering: `required`",
|
|
208
|
+
"- workflow_protocol: `required`",
|
|
209
|
+
"- capability: imported_skill_execution",
|
|
210
|
+
"- tuning: external_skill",
|
|
211
|
+
"- responsibility: execute imported external Skill behavior after XRefKit review",
|
|
212
|
+
]
|
|
213
|
+
if knowledge:
|
|
214
|
+
lines.append("- knowledge_slots:")
|
|
215
|
+
for item in knowledge:
|
|
216
|
+
safe_name = _slug(item.title).replace(".", "_").replace("-", "_")
|
|
217
|
+
lines.append(f" - name={safe_name}; bind={item.xid}")
|
|
218
|
+
lines.extend(
|
|
219
|
+
[
|
|
220
|
+
"- observation_refs:",
|
|
221
|
+
" - `conversion: external skill import`",
|
|
222
|
+
"",
|
|
223
|
+
"## Conversion Notes",
|
|
224
|
+
"",
|
|
225
|
+
f"- skill_doc_xid: `{skill_doc_xid}`",
|
|
226
|
+
"- source: external Skill converted by `convert_to_xrefkit_skill.py`",
|
|
227
|
+
]
|
|
228
|
+
)
|
|
229
|
+
text = "\n".join(lines) + "\n"
|
|
230
|
+
return _ensure_markdown_xid(text, known_xids)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def convert_skill(
|
|
234
|
+
*,
|
|
235
|
+
source_dir: Path,
|
|
236
|
+
repo_root: Path,
|
|
237
|
+
skill_id: str,
|
|
238
|
+
target_skill_dir: Path | None = None,
|
|
239
|
+
target_knowledge_dir: Path | None = None,
|
|
240
|
+
source_skill_doc: str | None = None,
|
|
241
|
+
source_root: Path | None = None,
|
|
242
|
+
dry_run: bool = False,
|
|
243
|
+
) -> ConversionResult:
|
|
244
|
+
source_dir = source_dir.resolve()
|
|
245
|
+
source_root = (source_root or source_dir).resolve()
|
|
246
|
+
repo_root = repo_root.resolve()
|
|
247
|
+
if not source_dir.is_dir():
|
|
248
|
+
raise FileNotFoundError(f"source skill directory not found: {source_dir}")
|
|
249
|
+
source_dir.relative_to(source_root)
|
|
250
|
+
|
|
251
|
+
skill_dir = (target_skill_dir or (repo_root / "skills_private" / _slug(skill_id))).resolve()
|
|
252
|
+
knowledge_dir = (target_knowledge_dir or (repo_root / "knowledge" / "imported_skills" / _slug(skill_id))).resolve()
|
|
253
|
+
source_doc = _find_skill_doc(source_dir, source_skill_doc)
|
|
254
|
+
|
|
255
|
+
known_xids: set[str] = set()
|
|
256
|
+
changed_files: list[str] = []
|
|
257
|
+
warnings: list[str] = []
|
|
258
|
+
imported: list[ImportedKnowledge] = []
|
|
259
|
+
source_to_import: dict[Path, ImportedKnowledge] = {}
|
|
260
|
+
|
|
261
|
+
skill_text = _read_text(source_doc)
|
|
262
|
+
skill_text, skill_xid = _ensure_markdown_xid(skill_text, known_xids)
|
|
263
|
+
|
|
264
|
+
def replace_link(match: re.Match[str]) -> str:
|
|
265
|
+
if match.group("prefix") == "!":
|
|
266
|
+
return match.group(0)
|
|
267
|
+
url = match.group("url")
|
|
268
|
+
target = _resolve_source_link(source_doc, url, source_root)
|
|
269
|
+
if target is None or target.resolve() == source_doc.resolve():
|
|
270
|
+
return match.group(0)
|
|
271
|
+
if target not in source_to_import:
|
|
272
|
+
target_path = _knowledge_target_path(target, source_root, knowledge_dir)
|
|
273
|
+
existing = _existing_knowledge_text(target_path, known_xids)
|
|
274
|
+
if existing is None:
|
|
275
|
+
knowledge_text, xid, title = _knowledge_text(target, source_root, skill_id, known_xids)
|
|
276
|
+
else:
|
|
277
|
+
knowledge_text, xid, title = existing
|
|
278
|
+
if _write_text(target_path, knowledge_text, dry_run=dry_run):
|
|
279
|
+
changed_files.append(_repo_rel(target_path, repo_root))
|
|
280
|
+
source_to_import[target] = ImportedKnowledge(
|
|
281
|
+
source_path=target.relative_to(source_root).as_posix(),
|
|
282
|
+
target_path=_repo_rel(target_path, repo_root),
|
|
283
|
+
xid=xid,
|
|
284
|
+
title=title,
|
|
285
|
+
)
|
|
286
|
+
imported.append(source_to_import[target])
|
|
287
|
+
item = source_to_import[target]
|
|
288
|
+
new_url = _relative_markdown_url(skill_dir / "SKILL.md", repo_root / item.target_path) + f"#xid-{item.xid}"
|
|
289
|
+
return f"[{match.group('label')}]({new_url}{match.group('rest')})"
|
|
290
|
+
|
|
291
|
+
normalized_skill_text = MARKDOWN_LINK_RE.sub(replace_link, skill_text)
|
|
292
|
+
if "## XRefKit Imported Knowledge" not in normalized_skill_text:
|
|
293
|
+
normalized_skill_text = normalized_skill_text.rstrip() + "\n\n## XRefKit Imported Knowledge\n\n"
|
|
294
|
+
if imported:
|
|
295
|
+
for item in imported:
|
|
296
|
+
url = _relative_markdown_url(skill_dir / "SKILL.md", repo_root / item.target_path)
|
|
297
|
+
normalized_skill_text += f"- [{item.title}]({url}#xid-{item.xid})\n"
|
|
298
|
+
else:
|
|
299
|
+
normalized_skill_text += "- none\n"
|
|
300
|
+
|
|
301
|
+
skill_doc_path = skill_dir / "SKILL.md"
|
|
302
|
+
if _write_text(skill_doc_path, normalized_skill_text, dry_run=dry_run):
|
|
303
|
+
changed_files.append(_repo_rel(skill_doc_path, repo_root))
|
|
304
|
+
|
|
305
|
+
meta, _meta_xid = _meta_text(skill_id=skill_id, skill_doc_xid=skill_xid, knowledge=imported, known_xids=known_xids)
|
|
306
|
+
meta_path = skill_dir / "meta.md"
|
|
307
|
+
if _write_text(meta_path, meta, dry_run=dry_run):
|
|
308
|
+
changed_files.append(_repo_rel(meta_path, repo_root))
|
|
309
|
+
|
|
310
|
+
return ConversionResult(
|
|
311
|
+
ok=True,
|
|
312
|
+
skill_id=skill_id,
|
|
313
|
+
skill_dir=_repo_rel(skill_dir, repo_root),
|
|
314
|
+
skill_doc=_repo_rel(skill_doc_path, repo_root),
|
|
315
|
+
meta_doc=_repo_rel(meta_path, repo_root),
|
|
316
|
+
imported_knowledge=imported,
|
|
317
|
+
changed_files=sorted(dict.fromkeys(changed_files)),
|
|
318
|
+
warnings=warnings,
|
|
319
|
+
dry_run=dry_run,
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def _discover_skill_dirs(source_root: Path) -> list[Path]:
|
|
324
|
+
skills_root = source_root / "skills"
|
|
325
|
+
if not skills_root.is_dir():
|
|
326
|
+
raise FileNotFoundError(f"batch source root must contain skills/: {source_root}")
|
|
327
|
+
skill_dirs: list[Path] = []
|
|
328
|
+
for path in sorted(skills_root.iterdir()):
|
|
329
|
+
if not path.is_dir():
|
|
330
|
+
continue
|
|
331
|
+
if any((path / name).is_file() for name in ("SKILL.md", "skill.md", "README.md", "readme.md")):
|
|
332
|
+
skill_dirs.append(path)
|
|
333
|
+
return skill_dirs
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def convert_skill_tree(
|
|
337
|
+
*,
|
|
338
|
+
source_root: Path,
|
|
339
|
+
repo_root: Path,
|
|
340
|
+
skill_id_prefix: str | None = None,
|
|
341
|
+
target_skill_root: Path | None = None,
|
|
342
|
+
target_knowledge_dir: Path | None = None,
|
|
343
|
+
dry_run: bool = False,
|
|
344
|
+
) -> BatchConversionResult:
|
|
345
|
+
source_root = source_root.resolve()
|
|
346
|
+
repo_root = repo_root.resolve()
|
|
347
|
+
batch_id = _slug(skill_id_prefix or source_root.name)
|
|
348
|
+
target_skill_root = (target_skill_root or (repo_root / "skills_private")).resolve()
|
|
349
|
+
target_knowledge_dir = (target_knowledge_dir or (repo_root / "knowledge" / "imported_skills" / batch_id)).resolve()
|
|
350
|
+
results: list[ConversionResult] = []
|
|
351
|
+
warnings: list[str] = []
|
|
352
|
+
|
|
353
|
+
for skill_dir in _discover_skill_dirs(source_root):
|
|
354
|
+
local_name = _slug(skill_dir.name)
|
|
355
|
+
skill_id = f"{batch_id}.{local_name}" if skill_id_prefix else local_name
|
|
356
|
+
result = convert_skill(
|
|
357
|
+
source_dir=skill_dir,
|
|
358
|
+
source_root=source_root,
|
|
359
|
+
repo_root=repo_root,
|
|
360
|
+
skill_id=skill_id,
|
|
361
|
+
target_skill_dir=target_skill_root / skill_id,
|
|
362
|
+
target_knowledge_dir=target_knowledge_dir,
|
|
363
|
+
dry_run=dry_run,
|
|
364
|
+
)
|
|
365
|
+
results.append(result)
|
|
366
|
+
warnings.extend(result.warnings)
|
|
367
|
+
|
|
368
|
+
changed_files = sorted(dict.fromkeys(path for result in results for path in result.changed_files))
|
|
369
|
+
return BatchConversionResult(
|
|
370
|
+
ok=all(result.ok for result in results),
|
|
371
|
+
source_root=source_root.as_posix(),
|
|
372
|
+
converted_skills=results,
|
|
373
|
+
changed_files=changed_files,
|
|
374
|
+
warnings=warnings,
|
|
375
|
+
dry_run=dry_run,
|
|
376
|
+
)
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def parse_args(argv: list[str]) -> argparse.Namespace:
|
|
380
|
+
parser = argparse.ArgumentParser(description="Convert an external Skill directory into XRefKit Skill + Knowledge files.")
|
|
381
|
+
parser.add_argument("source_dir", help="External Skill directory")
|
|
382
|
+
parser.add_argument("--repo-root", default=str(REPO_ROOT), help="XRefKit repository root")
|
|
383
|
+
parser.add_argument("--skill-id", help="Target XRefKit skill id")
|
|
384
|
+
parser.add_argument("--batch", action="store_true", help="Treat source_dir as a root containing skills/ and knowledge/")
|
|
385
|
+
parser.add_argument("--skill-id-prefix", default=None, help="Prefix for generated skill ids in --batch mode")
|
|
386
|
+
parser.add_argument("--source-skill-doc", default=None, help="Source skill document path relative to source_dir")
|
|
387
|
+
parser.add_argument("--target-skill-dir", default=None, help="Target Skill directory; defaults to skills_private/<skill-id>")
|
|
388
|
+
parser.add_argument("--target-skill-root", default=None, help="Batch target Skill root; defaults to skills_private/")
|
|
389
|
+
parser.add_argument("--target-knowledge-dir", default=None, help="Target Knowledge directory; defaults to knowledge/imported_skills/<skill-id>")
|
|
390
|
+
parser.add_argument("--dry-run", action="store_true", help="Show planned output without writing files")
|
|
391
|
+
parser.add_argument("--json", action="store_true", help="Emit JSON")
|
|
392
|
+
return parser.parse_args(argv)
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def _print_result(result: ConversionResult | BatchConversionResult, *, as_json: bool) -> int:
|
|
396
|
+
payload = result.to_dict()
|
|
397
|
+
if as_json:
|
|
398
|
+
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
|
399
|
+
else:
|
|
400
|
+
print(f"ok: {payload['ok']}")
|
|
401
|
+
if isinstance(result, BatchConversionResult):
|
|
402
|
+
for item in result.converted_skills:
|
|
403
|
+
print(f"skill: {item.skill_doc}")
|
|
404
|
+
for path in result.changed_files:
|
|
405
|
+
if path.startswith("knowledge/"):
|
|
406
|
+
print(f"knowledge: {path}")
|
|
407
|
+
else:
|
|
408
|
+
print(f"skill: {result.skill_doc}")
|
|
409
|
+
print(f"meta: {result.meta_doc}")
|
|
410
|
+
for item in result.imported_knowledge:
|
|
411
|
+
print(f"knowledge: {item.target_path}#xid-{item.xid}")
|
|
412
|
+
return 0 if result.ok else 1
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def cmd_skill_import(args: argparse.Namespace) -> int:
|
|
416
|
+
repo_root = Path(args.root)
|
|
417
|
+
if args.batch:
|
|
418
|
+
result = convert_skill_tree(
|
|
419
|
+
source_root=Path(args.source_dir),
|
|
420
|
+
repo_root=repo_root,
|
|
421
|
+
skill_id_prefix=args.skill_id_prefix,
|
|
422
|
+
target_skill_root=Path(args.target_skill_root) if args.target_skill_root else None,
|
|
423
|
+
target_knowledge_dir=Path(args.target_knowledge_dir) if args.target_knowledge_dir else None,
|
|
424
|
+
dry_run=bool(args.dry_run),
|
|
425
|
+
)
|
|
426
|
+
else:
|
|
427
|
+
if not args.skill_id:
|
|
428
|
+
raise SystemExit("--skill-id is required unless --batch is used")
|
|
429
|
+
result = convert_skill(
|
|
430
|
+
source_dir=Path(args.source_dir),
|
|
431
|
+
repo_root=repo_root,
|
|
432
|
+
skill_id=args.skill_id,
|
|
433
|
+
target_skill_dir=Path(args.target_skill_dir) if args.target_skill_dir else None,
|
|
434
|
+
target_knowledge_dir=Path(args.target_knowledge_dir) if args.target_knowledge_dir else None,
|
|
435
|
+
source_skill_doc=args.source_skill_doc,
|
|
436
|
+
dry_run=bool(args.dry_run),
|
|
437
|
+
)
|
|
438
|
+
return _print_result(result, as_json=bool(args.json))
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def main(argv: list[str] | None = None) -> int:
|
|
442
|
+
args = parse_args(argv or sys.argv[1:])
|
|
443
|
+
repo_root = Path(args.repo_root)
|
|
444
|
+
if args.batch:
|
|
445
|
+
result = convert_skill_tree(
|
|
446
|
+
source_root=Path(args.source_dir),
|
|
447
|
+
repo_root=repo_root,
|
|
448
|
+
skill_id_prefix=args.skill_id_prefix,
|
|
449
|
+
target_skill_root=Path(args.target_skill_root) if args.target_skill_root else None,
|
|
450
|
+
target_knowledge_dir=Path(args.target_knowledge_dir) if args.target_knowledge_dir else None,
|
|
451
|
+
dry_run=bool(args.dry_run),
|
|
452
|
+
)
|
|
453
|
+
else:
|
|
454
|
+
if not args.skill_id:
|
|
455
|
+
raise SystemExit("--skill-id is required unless --batch is used")
|
|
456
|
+
result = convert_skill(
|
|
457
|
+
source_dir=Path(args.source_dir),
|
|
458
|
+
repo_root=repo_root,
|
|
459
|
+
skill_id=args.skill_id,
|
|
460
|
+
target_skill_dir=Path(args.target_skill_dir) if args.target_skill_dir else None,
|
|
461
|
+
target_knowledge_dir=Path(args.target_knowledge_dir) if args.target_knowledge_dir else None,
|
|
462
|
+
source_skill_doc=args.source_skill_doc,
|
|
463
|
+
dry_run=bool(args.dry_run),
|
|
464
|
+
)
|
|
465
|
+
return _print_result(result, as_json=bool(args.json))
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
if __name__ == "__main__":
|
|
469
|
+
raise SystemExit(main())
|
xrefkit/instance.py
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""XRefKit instance manifest and bootstrap support."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import tomllib
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
MANIFEST_NAME = "xrefkit.toml"
|
|
13
|
+
VALID_AUTHORITIES = {
|
|
14
|
+
"legacy_authoritative",
|
|
15
|
+
"cutover_ready",
|
|
16
|
+
"xrefkit_authoritative",
|
|
17
|
+
}
|
|
18
|
+
STARTUP_XID = "C3A1F78D9B22"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True)
|
|
22
|
+
class InstanceManifest:
|
|
23
|
+
instance_id: str
|
|
24
|
+
command_authority: str
|
|
25
|
+
roots: tuple[str, ...]
|
|
26
|
+
startup_xid: str = STARTUP_XID
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def load_instance_manifest(path: str | Path) -> InstanceManifest:
|
|
30
|
+
manifest_path = Path(path)
|
|
31
|
+
data = tomllib.loads(manifest_path.read_text(encoding="utf-8"))
|
|
32
|
+
instance = data.get("instance", {})
|
|
33
|
+
runtime = data.get("runtime", {})
|
|
34
|
+
roots = data.get("roots", {})
|
|
35
|
+
authority = str(runtime.get("command_authority", ""))
|
|
36
|
+
if authority not in VALID_AUTHORITIES:
|
|
37
|
+
raise ValueError(f"invalid command_authority: {authority!r}")
|
|
38
|
+
values = tuple(str(value) for value in roots.get("content", ["."]))
|
|
39
|
+
return InstanceManifest(
|
|
40
|
+
instance_id=str(instance.get("id", "")).strip(),
|
|
41
|
+
command_authority=authority,
|
|
42
|
+
roots=values,
|
|
43
|
+
startup_xid=str(runtime.get("startup_xid", STARTUP_XID)),
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _manifest_text(instance_id: str) -> str:
|
|
48
|
+
return (
|
|
49
|
+
"[instance]\n"
|
|
50
|
+
f'id = "{instance_id}"\n\n'
|
|
51
|
+
"[runtime]\n"
|
|
52
|
+
'command_authority = "legacy_authoritative"\n'
|
|
53
|
+
f'startup_xid = "{STARTUP_XID}"\n\n'
|
|
54
|
+
"[roots]\n"
|
|
55
|
+
'content = ["."]\n'
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _startup_text(client: str) -> str:
|
|
60
|
+
title = {"AGENTS.md": "AGENTS", "CLAUDE.md": "CLAUDE", "CHATGPT.md": "CHATGPT"}[client]
|
|
61
|
+
return (
|
|
62
|
+
f"# {title} Startup (XRefKit)\n\n"
|
|
63
|
+
"**As your first action**, resolve and apply the XRefKit startup contract:\n\n"
|
|
64
|
+
f"- `docs/core/contracts/080_xrefkit_startup_contract.md#xid-{STARTUP_XID}`\n"
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def initialize_instance(root: str | Path, *, instance_id: str, startup_files: bool) -> dict[str, object]:
|
|
69
|
+
root_path = Path(root).resolve()
|
|
70
|
+
root_path.mkdir(parents=True, exist_ok=True)
|
|
71
|
+
manifest_path = root_path / MANIFEST_NAME
|
|
72
|
+
created: list[str] = []
|
|
73
|
+
preserved: list[str] = []
|
|
74
|
+
if manifest_path.exists():
|
|
75
|
+
preserved.append(MANIFEST_NAME)
|
|
76
|
+
else:
|
|
77
|
+
manifest_path.write_text(_manifest_text(instance_id), encoding="utf-8")
|
|
78
|
+
created.append(MANIFEST_NAME)
|
|
79
|
+
|
|
80
|
+
if startup_files:
|
|
81
|
+
for name in ("AGENTS.md", "CLAUDE.md", "CHATGPT.md"):
|
|
82
|
+
path = root_path / name
|
|
83
|
+
if path.exists():
|
|
84
|
+
preserved.append(name)
|
|
85
|
+
else:
|
|
86
|
+
path.write_text(_startup_text(name), encoding="utf-8")
|
|
87
|
+
created.append(name)
|
|
88
|
+
|
|
89
|
+
manifest = load_instance_manifest(manifest_path)
|
|
90
|
+
if not manifest.instance_id:
|
|
91
|
+
raise ValueError("instance.id must not be empty")
|
|
92
|
+
return {
|
|
93
|
+
"ok": True,
|
|
94
|
+
"root": str(root_path),
|
|
95
|
+
"manifest": str(manifest_path),
|
|
96
|
+
"instance_id": manifest.instance_id,
|
|
97
|
+
"command_authority": manifest.command_authority,
|
|
98
|
+
"startup_xid": manifest.startup_xid,
|
|
99
|
+
"roots": list(manifest.roots),
|
|
100
|
+
"created": created,
|
|
101
|
+
"preserved": preserved,
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def main(argv: list[str] | None = None) -> int:
|
|
106
|
+
parser = argparse.ArgumentParser(prog="xrefkit init")
|
|
107
|
+
parser.add_argument("--root", default=".")
|
|
108
|
+
parser.add_argument("--instance-id")
|
|
109
|
+
parser.add_argument("--no-startup-files", action="store_true")
|
|
110
|
+
parser.add_argument("--json", action="store_true")
|
|
111
|
+
args = parser.parse_args(argv)
|
|
112
|
+
root = Path(args.root).resolve()
|
|
113
|
+
instance_id = args.instance_id or root.name.lower().replace(" ", "-")
|
|
114
|
+
try:
|
|
115
|
+
result = initialize_instance(
|
|
116
|
+
root,
|
|
117
|
+
instance_id=instance_id,
|
|
118
|
+
startup_files=not args.no_startup_files,
|
|
119
|
+
)
|
|
120
|
+
except (OSError, ValueError, tomllib.TOMLDecodeError) as exc:
|
|
121
|
+
result = {"ok": False, "error": str(exc)}
|
|
122
|
+
if args.json:
|
|
123
|
+
print(json.dumps(result, ensure_ascii=False, indent=2))
|
|
124
|
+
else:
|
|
125
|
+
print(f"error: {exc}")
|
|
126
|
+
return 1
|
|
127
|
+
if args.json:
|
|
128
|
+
print(json.dumps(result, ensure_ascii=False, indent=2))
|
|
129
|
+
else:
|
|
130
|
+
print(f"instance: {result['instance_id']}")
|
|
131
|
+
print(f"manifest: {result['manifest']}")
|
|
132
|
+
print(f"command_authority: {result['command_authority']}")
|
|
133
|
+
return 0
|
xrefkit/loaders.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Filesystem loaders for XRefKit v2 MVP assets."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, TypeVar
|
|
8
|
+
|
|
9
|
+
import yaml
|
|
10
|
+
from pydantic import BaseModel
|
|
11
|
+
|
|
12
|
+
from .models import LocalDomainSkill, LocalManifest, PackageManifest, SkillDefinition, XRefKitServerConfig
|
|
13
|
+
|
|
14
|
+
try: # Python 3.11+
|
|
15
|
+
import tomllib
|
|
16
|
+
except ModuleNotFoundError: # pragma: no cover - used on Python 3.10
|
|
17
|
+
import tomli as tomllib # type: ignore[no-redef]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
ModelT = TypeVar("ModelT", bound=BaseModel)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _read_yaml(path: Path) -> dict[str, Any]:
|
|
24
|
+
with path.open("r", encoding="utf-8") as handle:
|
|
25
|
+
data = yaml.safe_load(handle) or {}
|
|
26
|
+
if not isinstance(data, dict):
|
|
27
|
+
raise ValueError(f"{path} must contain a mapping")
|
|
28
|
+
return data
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _read_json(path: Path) -> dict[str, Any]:
|
|
32
|
+
with path.open("r", encoding="utf-8") as handle:
|
|
33
|
+
data = json.load(handle)
|
|
34
|
+
if not isinstance(data, dict):
|
|
35
|
+
raise ValueError(f"{path} must contain an object")
|
|
36
|
+
return data
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _read_toml(path: Path) -> dict[str, Any]:
|
|
40
|
+
with path.open("rb") as handle:
|
|
41
|
+
data = tomllib.load(handle)
|
|
42
|
+
if not isinstance(data, dict):
|
|
43
|
+
raise ValueError(f"{path} must contain a table")
|
|
44
|
+
return data
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _load_model(path: Path, model_type: type[ModelT]) -> ModelT:
|
|
48
|
+
suffix = path.suffix.lower()
|
|
49
|
+
if suffix == ".toml":
|
|
50
|
+
data = _read_toml(path)
|
|
51
|
+
elif suffix in {".yaml", ".yml"}:
|
|
52
|
+
data = _read_yaml(path)
|
|
53
|
+
elif suffix == ".json":
|
|
54
|
+
data = _read_json(path)
|
|
55
|
+
else:
|
|
56
|
+
raise ValueError(f"unsupported file extension for {path}")
|
|
57
|
+
return model_type.model_validate(data)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def load_server_config(path: str | Path) -> XRefKitServerConfig:
|
|
61
|
+
return _load_model(Path(path), XRefKitServerConfig)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def load_package_manifest(path: str | Path) -> PackageManifest:
|
|
65
|
+
return _load_model(Path(path), PackageManifest)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def load_skill_definition(path: str | Path) -> SkillDefinition:
|
|
69
|
+
return _load_model(Path(path), SkillDefinition)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def load_local_manifest(path: str | Path) -> LocalManifest:
|
|
73
|
+
return _load_model(Path(path), LocalManifest)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def load_local_domain_skill(path: str | Path) -> LocalDomainSkill:
|
|
77
|
+
return _load_model(Path(path), LocalDomainSkill)
|