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.
Files changed (65) hide show
  1. xrefkit/__init__.py +5 -0
  2. xrefkit/__main__.py +5 -0
  3. xrefkit/catalog_cli.py +57 -0
  4. xrefkit/cli.py +71 -0
  5. xrefkit/contracts.py +297 -0
  6. xrefkit/ctx.py +160 -0
  7. xrefkit/dashboard.py +1220 -0
  8. xrefkit/discovery.py +85 -0
  9. xrefkit/gate.py +428 -0
  10. xrefkit/goalstate.py +555 -0
  11. xrefkit/hashing.py +18 -0
  12. xrefkit/import_skill.py +469 -0
  13. xrefkit/instance.py +133 -0
  14. xrefkit/loaders.py +77 -0
  15. xrefkit/mcp/__init__.py +26 -0
  16. xrefkit/mcp/audit.py +168 -0
  17. xrefkit/mcp/bootstrap.py +337 -0
  18. xrefkit/mcp/catalog.py +2638 -0
  19. xrefkit/mcp/cli.py +173 -0
  20. xrefkit/mcp/client_cache.py +356 -0
  21. xrefkit/mcp/context_registry.py +277 -0
  22. xrefkit/mcp/contracts.py +243 -0
  23. xrefkit/mcp/dist.py +234 -0
  24. xrefkit/mcp/ownership.py +246 -0
  25. xrefkit/mcp/repository.py +217 -0
  26. xrefkit/mcp/schemas.py +349 -0
  27. xrefkit/mcp/server.py +773 -0
  28. xrefkit/mcp/startup_contract_pack.py +154 -0
  29. xrefkit/mcp_tools.py +47 -0
  30. xrefkit/models/__init__.py +41 -0
  31. xrefkit/models/common.py +185 -0
  32. xrefkit/models/effective_bundle.py +131 -0
  33. xrefkit/models/local_manifest.py +217 -0
  34. xrefkit/models/package_manifest.py +126 -0
  35. xrefkit/models/run_log.py +276 -0
  36. xrefkit/models/server_config.py +160 -0
  37. xrefkit/models/skill_definition.py +131 -0
  38. xrefkit/operations_cli.py +670 -0
  39. xrefkit/ownership.py +276 -0
  40. xrefkit/packmeta.py +289 -0
  41. xrefkit/registry.py +334 -0
  42. xrefkit/resolver.py +252 -0
  43. xrefkit/resource_provider.py +187 -0
  44. xrefkit/resources/base/contracts.json +178 -0
  45. xrefkit/resources/base/current.json +6 -0
  46. xrefkit/resources/base/generations/7a682a5272907354/contracts.json +178 -0
  47. xrefkit/resources/base/generations/7a682a5272907354/model_body.md +22 -0
  48. xrefkit/resources/base/generations/9929294385ccb7b0/contracts.json +178 -0
  49. xrefkit/resources/base/generations/9929294385ccb7b0/model_body.md +22 -0
  50. xrefkit/resources/base/model_body.md +22 -0
  51. xrefkit/runlog.py +45 -0
  52. xrefkit/skillmeta.py +1034 -0
  53. xrefkit/skillrun.py +2381 -0
  54. xrefkit/structure_catalog.py +199 -0
  55. xrefkit/tools/__init__.py +119 -0
  56. xrefkit/tools/__main__.py +4 -0
  57. xrefkit/v2_cli.py +130 -0
  58. xrefkit/workspace.py +117 -0
  59. xrefkit/xref.py +1048 -0
  60. xrefkit-0.3.0.dist-info/METADATA +203 -0
  61. xrefkit-0.3.0.dist-info/RECORD +65 -0
  62. xrefkit-0.3.0.dist-info/WHEEL +5 -0
  63. xrefkit-0.3.0.dist-info/entry_points.txt +2 -0
  64. xrefkit-0.3.0.dist-info/licenses/LICENSE +21 -0
  65. xrefkit-0.3.0.dist-info/top_level.txt +1 -0
@@ -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)