ktl 0.64.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 (189) hide show
  1. ktl/.claude-plugin/marketplace.json +16 -0
  2. ktl/.claude-plugin/plugin.json +10 -0
  3. ktl/README.md +59 -0
  4. ktl/__init__.py +1 -0
  5. ktl/__main__.py +5 -0
  6. ktl/cli.py +390 -0
  7. ktl/gh.py +1746 -0
  8. ktl/skills/ktl-curator/SKILL.md +117 -0
  9. ktl/skills/ktl-curator/references/domain-bundle.md +98 -0
  10. ktl/skills/ktl-curator/references/domain-vocabularies.md +60 -0
  11. ktl/skills/ktl-curator/references/portability.md +38 -0
  12. ktl/skills/ktl-curator/references/queries.md +103 -0
  13. ktl/skills/ktl-curator/references/review-session.md +288 -0
  14. ktl/skills/ktl-curator/references/trust-fields.md +138 -0
  15. ktl/skills/ktl-docent/SKILL.md +108 -0
  16. ktl/skills/ktl-docent/references/answering.md +110 -0
  17. ktl/skills/ktl-docent/references/feedback.md +78 -0
  18. ktl/skills/ktl-docent/references/obsidian.md +38 -0
  19. ktl/skills/ktl-docent/scripts/knowledge-feedback.sh +199 -0
  20. ktl/skills/ktl-docent/scripts/knowledge-report.sh +2085 -0
  21. ktl/skills/ktl-librarian/SKILL.md +145 -0
  22. ktl/skills/ktl-librarian/references/agent-skills.md +41 -0
  23. ktl/skills/ktl-librarian/references/audit.md +31 -0
  24. ktl/skills/ktl-librarian/references/bootstrap.md +23 -0
  25. ktl/skills/ktl-librarian/references/domain-vocabulary.md +154 -0
  26. ktl/skills/ktl-librarian/references/excerpts.md +105 -0
  27. ktl/skills/ktl-librarian/references/golden-rules.md +107 -0
  28. ktl/skills/ktl-librarian/references/ingest.md +46 -0
  29. ktl/skills/ktl-librarian/references/patch.md +143 -0
  30. ktl/skills/ktl-librarian/references/portability.md +24 -0
  31. ktl/skills/ktl-librarian/references/retellings.md +62 -0
  32. ktl/skills/ktl-librarian/references/scheduled-task.md +61 -0
  33. ktl/skills/ktl-prose/SKILL.md +190 -0
  34. ktl/skills/ktl-prose/assets/voa-word-book.txt +1642 -0
  35. ktl/skills/ktl-prose/references/limits.md +70 -0
  36. ktl/skills/ktl-prose/references/other-files.md +83 -0
  37. ktl/skills/ktl-prose/references/rules.md +359 -0
  38. ktl/skills/ktl-prose/references/sources.md +108 -0
  39. ktl/skills/ktl-prose/references/young-readers.md +167 -0
  40. ktl/skills/ktl-prose/scripts/prose-check.py +2597 -0
  41. ktl/skills/ktl-sidecar/SKILL.md +185 -0
  42. ktl/skills/ktl-sidecar/references/automation.md +473 -0
  43. ktl/skills/ktl-sidecar/references/gate.md +154 -0
  44. ktl/skills/ktl-sidecar/references/m365.md +50 -0
  45. ktl/skills/ktl-sidecar/references/portability.md +122 -0
  46. ktl/skills/ktl-sidecar/references/prerequisites.md +32 -0
  47. ktl/skills/ktl-sidecar/references/repository-settings.json +457 -0
  48. ktl/skills/ktl-sidecar/references/repository-settings.md +66 -0
  49. ktl/skills/ktl-sidecar/templates/README.md +76 -0
  50. ktl/skills/ktl-sidecar/templates/domain.yaml +129 -0
  51. ktl/skills/ktl-sidecar/templates/gitattributes +7 -0
  52. ktl/skills/ktl-sidecar/templates/github/knowledge-federation.yaml +559 -0
  53. ktl/skills/ktl-sidecar/templates/github/knowledge-ingest-pull-request.md +12 -0
  54. ktl/skills/ktl-sidecar/templates/github/knowledge-ingest.yaml +330 -0
  55. ktl/skills/ktl-sidecar/templates/github/knowledge-librarian.yaml +778 -0
  56. ktl/skills/ktl-sidecar/templates/github/knowledge-registrar.yaml +584 -0
  57. ktl/skills/ktl-sidecar/templates/github/knowledge-release.yaml +622 -0
  58. ktl/skills/ktl-sidecar/templates/github/knowledge-sync-pull-request.md +9 -0
  59. ktl/skills/ktl-sidecar/templates/github/knowledge-sync.yaml +692 -0
  60. ktl/skills/ktl-sidecar/templates/gitignore +37 -0
  61. ktl/skills/ktl-sidecar/templates/ingest.yaml +75 -0
  62. ktl/skills/ktl-sidecar/templates/justfile +72 -0
  63. ktl/skills/ktl-sidecar/templates/knowledge/index.md +22 -0
  64. ktl/skills/ktl-sidecar/templates/knowledge/log.md +6 -0
  65. ktl/skills/ktl-sidecar/templates/knowledge/services/example-service-a.md +13 -0
  66. ktl/skills/ktl-sidecar/templates/knowledge/services/example-service-b.md +14 -0
  67. ktl/skills/ktl-sidecar/templates/knowledge/services/index.md +4 -0
  68. ktl/skills/ktl-sidecar/templates/knowledge-sync.manifest +91 -0
  69. ktl/skills/ktl-sidecar/templates/llms.txt +24 -0
  70. ktl/skills/ktl-sidecar/templates/m365/knowledge-m365.sh +238 -0
  71. ktl/skills/ktl-sidecar/templates/m365/ktl-docent-m365.md +112 -0
  72. ktl/skills/ktl-sidecar/templates/pyproject.toml +22 -0
  73. ktl/skills/ktl-sidecar/templates/queries.http +54 -0
  74. ktl/skills/ktl-sidecar/templates/readme-for-ai-agents.md +13 -0
  75. ktl/skills/ktl-sidecar/templates/scripts/knowledge-apply.py +1600 -0
  76. ktl/skills/ktl-sidecar/templates/scripts/knowledge-apply.sh +93 -0
  77. ktl/skills/ktl-sidecar/templates/scripts/knowledge-conventions.py +898 -0
  78. ktl/skills/ktl-sidecar/templates/scripts/knowledge-conventions.sh +466 -0
  79. ktl/skills/ktl-sidecar/templates/scripts/knowledge-federate.py +4010 -0
  80. ktl/skills/ktl-sidecar/templates/scripts/knowledge-feedback.sh +199 -0
  81. ktl/skills/ktl-sidecar/templates/scripts/knowledge-ingest.py +2970 -0
  82. ktl/skills/ktl-sidecar/templates/scripts/knowledge-ingest.sh +87 -0
  83. ktl/skills/ktl-sidecar/templates/scripts/knowledge-librarian.sh +550 -0
  84. ktl/skills/ktl-sidecar/templates/scripts/knowledge-preflight.sh +553 -0
  85. ktl/skills/ktl-sidecar/templates/scripts/knowledge-provenance.sh +518 -0
  86. ktl/skills/ktl-sidecar/templates/scripts/knowledge-report.sh +2085 -0
  87. ktl/skills/ktl-sidecar/templates/scripts/knowledge-sync.sh +853 -0
  88. ktl/skills/ktl-website/SKILL.md +138 -0
  89. ktl/skills/ktl-website/references/customising.md +53 -0
  90. ktl/skills/ktl-website/references/deploy.md +93 -0
  91. ktl/skills/ktl-website/references/inputs.md +94 -0
  92. ktl/skills/ktl-website/references/prerequisites.md +28 -0
  93. ktl/skills/ktl-website/references/repository-settings.json +50 -0
  94. ktl/skills/ktl-website/scripts/website-preflight.sh +312 -0
  95. ktl/skills/ktl-website/templates/github/knowledge-website.yaml +176 -0
  96. ktl/skills/ktl-website/templates/website/README.md +57 -0
  97. ktl/skills/ktl-website/templates/website/assets/card.png +0 -0
  98. ktl/skills/ktl-website/templates/website/assets/css/custom.css +3 -0
  99. ktl/skills/ktl-website/templates/website/assets/css/site.css +356 -0
  100. ktl/skills/ktl-website/templates/website/assets/js/graph.js +299 -0
  101. ktl/skills/ktl-website/templates/website/assets/js/site.js +235 -0
  102. ktl/skills/ktl-website/templates/website/content/_content.gotmpl +41 -0
  103. ktl/skills/ktl-website/templates/website/content/browse/_content.gotmpl +15 -0
  104. ktl/skills/ktl-website/templates/website/content/changelog/_content.gotmpl +40 -0
  105. ktl/skills/ktl-website/templates/website/content/docs/_content.gotmpl +49 -0
  106. ktl/skills/ktl-website/templates/website/content/graph.md +4 -0
  107. ktl/skills/ktl-website/templates/website/content/health.md +4 -0
  108. ktl/skills/ktl-website/templates/website/content/knowledge/_content.gotmpl +66 -0
  109. ktl/skills/ktl-website/templates/website/content/schema/_content.gotmpl +34 -0
  110. ktl/skills/ktl-website/templates/website/gitignore +9 -0
  111. ktl/skills/ktl-website/templates/website/hugo.toml +158 -0
  112. ktl/skills/ktl-website/templates/website/layouts/404.html +6 -0
  113. ktl/skills/ktl-website/templates/website/layouts/_markup/render-blockquote.html +8 -0
  114. ktl/skills/ktl-website/templates/website/layouts/_markup/render-codeblock-mermaid.html +2 -0
  115. ktl/skills/ktl-website/templates/website/layouts/_markup/render-heading.html +4 -0
  116. ktl/skills/ktl-website/templates/website/layouts/_markup/render-image.html +3 -0
  117. ktl/skills/ktl-website/templates/website/layouts/_markup/render-link.html +5 -0
  118. ktl/skills/ktl-website/templates/website/layouts/_partials/actor.html +4 -0
  119. ktl/skills/ktl-website/templates/website/layouts/_partials/card.html +15 -0
  120. ktl/skills/ktl-website/templates/website/layouts/_partials/concept-card.html +97 -0
  121. ktl/skills/ktl-website/templates/website/layouts/_partials/concept-link.html +6 -0
  122. ktl/skills/ktl-website/templates/website/layouts/_partials/concepts.html +17 -0
  123. ktl/skills/ktl-website/templates/website/layouts/_partials/content.html +2 -0
  124. ktl/skills/ktl-website/templates/website/layouts/_partials/docs-groups.html +62 -0
  125. ktl/skills/ktl-website/templates/website/layouts/_partials/full-url.html +16 -0
  126. ktl/skills/ktl-website/templates/website/layouts/_partials/gendoc.html +30 -0
  127. ktl/skills/ktl-website/templates/website/layouts/_partials/health-tiles.html +21 -0
  128. ktl/skills/ktl-website/templates/website/layouts/_partials/heroes.html +43 -0
  129. ktl/skills/ktl-website/templates/website/layouts/_partials/icon.html +8 -0
  130. ktl/skills/ktl-website/templates/website/layouts/_partials/inline.html +2 -0
  131. ktl/skills/ktl-website/templates/website/layouts/_partials/jsonld.html +8 -0
  132. ktl/skills/ktl-website/templates/website/layouts/_partials/labels.html +9 -0
  133. ktl/skills/ktl-website/templates/website/layouts/_partials/levels.html +40 -0
  134. ktl/skills/ktl-website/templates/website/layouts/_partials/licence.html +9 -0
  135. ktl/skills/ktl-website/templates/website/layouts/_partials/literal.html +8 -0
  136. ktl/skills/ktl-website/templates/website/layouts/_partials/markdown-file.html +23 -0
  137. ktl/skills/ktl-website/templates/website/layouts/_partials/markdown.html +59 -0
  138. ktl/skills/ktl-website/templates/website/layouts/_partials/mirrors.html +28 -0
  139. ktl/skills/ktl-website/templates/website/layouts/_partials/nav-key.html +21 -0
  140. ktl/skills/ktl-website/templates/website/layouts/_partials/neighbourhood.html +51 -0
  141. ktl/skills/ktl-website/templates/website/layouts/_partials/pager.html +15 -0
  142. ktl/skills/ktl-website/templates/website/layouts/_partials/preview.html +3 -0
  143. ktl/skills/ktl-website/templates/website/layouts/_partials/raw.html +26 -0
  144. ktl/skills/ktl-website/templates/website/layouts/_partials/repo-path.html +6 -0
  145. ktl/skills/ktl-website/templates/website/layouts/_partials/route.html +39 -0
  146. ktl/skills/ktl-website/templates/website/layouts/_partials/routes.html +9 -0
  147. ktl/skills/ktl-website/templates/website/layouts/_partials/schema-file.html +17 -0
  148. ktl/skills/ktl-website/templates/website/layouts/_partials/sidebar.html +83 -0
  149. ktl/skills/ktl-website/templates/website/layouts/_partials/slot-name.html +2 -0
  150. ktl/skills/ktl-website/templates/website/layouts/_partials/title.html +9 -0
  151. ktl/skills/ktl-website/templates/website/layouts/_partials/tones.html +8 -0
  152. ktl/skills/ktl-website/templates/website/layouts/_partials/tools.html +27 -0
  153. ktl/skills/ktl-website/templates/website/layouts/_partials/trust.html +16 -0
  154. ktl/skills/ktl-website/templates/website/layouts/_partials/vendor.html +16 -0
  155. ktl/skills/ktl-website/templates/website/layouts/baseof.html +119 -0
  156. ktl/skills/ktl-website/templates/website/layouts/browse/section.html +20 -0
  157. ktl/skills/ktl-website/templates/website/layouts/changelog/page.html +15 -0
  158. ktl/skills/ktl-website/templates/website/layouts/changelog/section.html +12 -0
  159. ktl/skills/ktl-website/templates/website/layouts/graph.html +54 -0
  160. ktl/skills/ktl-website/templates/website/layouts/health.html +39 -0
  161. ktl/skills/ktl-website/templates/website/layouts/home.html +36 -0
  162. ktl/skills/ktl-website/templates/website/layouts/home.llms.txt +32 -0
  163. ktl/skills/ktl-website/templates/website/layouts/home.llmsfull.txt +20 -0
  164. ktl/skills/ktl-website/templates/website/layouts/page.atom.xml +28 -0
  165. ktl/skills/ktl-website/templates/website/layouts/page.html +10 -0
  166. ktl/skills/ktl-website/templates/website/layouts/page.jsonld.jsonld +2 -0
  167. ktl/skills/ktl-website/templates/website/layouts/redirect.html +14 -0
  168. ktl/skills/ktl-website/templates/website/layouts/schema/page.html +10 -0
  169. ktl/skills/ktl-website/templates/website/layouts/schema/section.html +29 -0
  170. ktl/skills/ktl-website/templates/website/layouts/section.html +51 -0
  171. ktl/skills/ktl-website/templates/website/pyproject.toml +12 -0
  172. ktl/skills/ktl-website/templates/website/scripts/build.sh +69 -0
  173. ktl/skills/ktl-website/templates/website/scripts/check-links.py +107 -0
  174. ktl/skills/ktl-website/templates/website/scripts/clean-html.py +167 -0
  175. ktl/skills/ktl-website/templates/website/scripts/prepare.sh +141 -0
  176. ktl/skills/ktl-website/templates/website/site.toml +55 -0
  177. ktl/skills/ktl-website/templates/website/static/brand/knowledge-trust-ladder-logo-dark.svg +22 -0
  178. ktl/skills/ktl-website/templates/website/static/brand/knowledge-trust-ladder-logo.svg +22 -0
  179. ktl/skills/ktl-website/templates/website/static/fonts/OFL-atkinson-hyperlegible-next.txt +93 -0
  180. ktl/skills/ktl-website/templates/website/static/fonts/OFL-source-serif-4.txt +93 -0
  181. ktl/skills/ktl-website/templates/website/static/fonts/atkinson-hyperlegible-next-latin-wght-italic.woff2 +0 -0
  182. ktl/skills/ktl-website/templates/website/static/fonts/atkinson-hyperlegible-next-latin-wght-normal.woff2 +0 -0
  183. ktl/skills/ktl-website/templates/website/static/fonts/source-serif-4-latin-wght-normal.woff2 +0 -0
  184. ktl/skills/ktl-website/templates/website/uv.lock +62 -0
  185. ktl-0.64.0.dist-info/METADATA +81 -0
  186. ktl-0.64.0.dist-info/RECORD +189 -0
  187. ktl-0.64.0.dist-info/WHEEL +4 -0
  188. ktl-0.64.0.dist-info/entry_points.txt +2 -0
  189. ktl-0.64.0.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,16 @@
1
+ {
2
+ "name": "knowledge-trust-ladder",
3
+ "owner": { "name": "Noel McLoughlin", "url": "https://github.com/noelmcloughlin" },
4
+ "metadata": {
5
+ "description": "Agent skills that keep a LOKF knowledge bundle true, and record who checked each claim."
6
+ },
7
+ "plugins": [
8
+ {
9
+ "name": "knowledge-trust-ladder",
10
+ "source": "./",
11
+ "description": "Four agent skills that keep a LOKF knowledge bundle current beside any repository, and record who checked each claim. A fifth skill, ktl-prose, rewords a bundle's agent-written concepts in plain English, and a sixth, ktl-website, builds a website for the bundle.",
12
+ "category": "knowledge-management",
13
+ "keywords": ["okf", "open-knowledge-format", "lokf", "linkml", "knowledge-graph", "linked-data", "json-ld", "rdf", "sparql", "provenance", "trust-ladder", "human-review", "agent-skills", "wikiskill", "website"]
14
+ }
15
+ ]
16
+ }
@@ -0,0 +1,10 @@
1
+ {
2
+ "name": "knowledge-trust-ladder",
3
+ "displayName": "Knowledge Trust Ladder",
4
+ "description": "Four agent skills that keep a LOKF knowledge bundle (Linked Open Knowledge Format, a semantic profile of OKF) current beside any repository, and record who checked each claim: a sidecar, a librarian, a curator and a docent. A fifth skill, ktl-prose, rewords a bundle's agent-written concepts in plain English, and a sixth, ktl-website, builds a website for the bundle.",
5
+ "author": { "name": "Noel McLoughlin", "url": "https://github.com/noelmcloughlin" },
6
+ "homepage": "https://w3id.org/ktl/",
7
+ "repository": "https://github.com/noelmcloughlin/knowledge-trust-ladder",
8
+ "license": "Apache-2.0",
9
+ "keywords": ["okf", "open-knowledge-format", "lokf", "linkml", "knowledge-graph", "linked-data", "json-ld", "rdf", "sparql", "provenance", "trust-ladder", "human-review", "agent-skills", "wikiskill", "website"]
10
+ }
ktl/README.md ADDED
@@ -0,0 +1,59 @@
1
+ # ktl
2
+
3
+ The six Knowledge Trust Ladder (KTL) agent skills, and a command that installs them for GitHub Copilot, Claude Code, Codex or any other agent that reads skill folders, and sets the GitHub settings their workflows need. The skills keep a LOKF knowledge bundle beside a repository true, and record who checked each claim. [The KTL site](https://w3id.org/ktl/) says what each skill does.
4
+
5
+ The package has no dependencies, needs Python 3.9 or later, and installs from PyPI or any mirror of it, so it serves a machine that cannot reach github.com.
6
+
7
+ ## Install the skills
8
+
9
+ Install all six for your user:
10
+
11
+ ```bash
12
+ uvx ktl skills install --scope user
13
+ ```
14
+
15
+ That installs for GitHub Copilot, as `gh skill install` does. For Claude Code or Codex, add `--agent claude-code` or `--agent codex`. `--scope` takes `user` or `project`, and `project` means the top folder of the repository you are in. For any other agent, give the folder it reads with `--dest`. To install one skill, add `--skill ktl-docent`. To pin a version, write `ktl@X.Y.Z` in place of `ktl`, or `ktl==X.Y.Z` to pip. The six skills are released together, so one version pins them all. To update, run the command again as `uvx ktl@latest`, or `uv tool upgrade ktl` where `uv tool install` put the package.
16
+
17
+ Without uv, pipx does the same:
18
+
19
+ ```bash
20
+ pipx run ktl skills install --scope user
21
+ ```
22
+
23
+ `python3 -m pip install --user ktl`, then `python3 -m ktl skills install --scope user`, works where the system lets pip install for a user. Debian, Ubuntu and Homebrew refuse it.
24
+
25
+ ## Check what is installed
26
+
27
+ `ktl skills status` says, for each folder an agent reads at project and user scope, whether each KTL skill there is the same as this package's, and names each file that differs. A copy `gh skill install` made counts as the same when only gh's own rewrite of its `SKILL.md` differs. It exits 1 when a copy differs, so a script can act on it. `--dest` checks one folder.
28
+
29
+ ## Set the GitHub settings
30
+
31
+ The workflows the skills install read repository variables, secrets kept in Environments, and a few repository settings. `ktl gh` compares what each skill's catalogue asks for with a repository's settings, through the GitHub CLI and your own login, and sets what is missing:
32
+
33
+ ```bash
34
+ uvx ktl gh plan # what is missing, and why; changes nothing
35
+ uvx ktl gh apply # the Environments, their branch rules, and the values a catalogue gives
36
+ uvx ktl gh secrets # each secret, asked for without an echo
37
+ uvx ktl gh enable librarian # one switch, once what it needs is in place
38
+ uvx ktl gh prune # the variables a release retired
39
+ ```
40
+
41
+ Run it in a clone, or name a repository with `--repo owner/name`. It needs `gh auth login` and admin rights on the repository. `apply` never loosens a setting and never turns a switch on, and each verb that changes something prints the change and asks first. [Turn on the workflows](https://w3id.org/ktl/docs/install/#turn-on-the-workflows) says what each workflow needs, and links the page on each verb.
42
+
43
+ ## Other installers
44
+
45
+ `ktl skills path` prints the folder that holds the skills. It is the argument for the installers that take a local folder:
46
+
47
+ ```bash
48
+ gh skill install "$(ktl skills path)" --from-local --all --scope user --agent github-copilot
49
+ npx skills add "$(ktl skills path)" --global --yes
50
+ ```
51
+
52
+ The package folder is also a Claude Code plugin marketplace. Install the package with `uv tool install ktl` first, so that the folder stays in place, then:
53
+
54
+ ```bash
55
+ claude plugin marketplace add "$(ktl skills path --marketplace)"
56
+ claude plugin install knowledge-trust-ladder@knowledge-trust-ladder
57
+ ```
58
+
59
+ `ktl skills` names the six skills. The package also goes by the repository's name: `knowledge-trust-ladder` installs `ktl` at the same version. Each release's files can be rebuilt from its tag and compared byte for byte, as [the release page](https://w3id.org/ktl/docs/releasing/#the-ktl-package-on-pypi) says. [The install page](https://w3id.org/ktl/docs/install/) gives every other route, and what each skill needs on the machine.
ktl/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """Knowledge Trust Ladder (KTL): the six agent skills, a command that installs them, and one that sets the GitHub settings their workflows need."""
ktl/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from ktl.cli import main
4
+
5
+ sys.exit(main())
ktl/cli.py ADDED
@@ -0,0 +1,390 @@
1
+ """The `ktl` command: list, locate, install and check the Knowledge Trust
2
+ Ladder skills that this package carries, and, with `ktl gh`, set the GitHub
3
+ settings the workflows those skills install need (src/ktl/gh.py).
4
+
5
+ The package holds the six skills under `ktl/skills/` and the Claude Code
6
+ plugin manifest under `ktl/.claude-plugin/`, each byte for byte as the
7
+ release tag holds them. `ktl skills install` copies skill folders into the
8
+ folder an agent reads, and calls no other tool, so it works the same on
9
+ every operating system and forge, and with no network once the package is
10
+ installed. `gh skill install --from-local` and `npx skills add` take the
11
+ folder `ktl skills path` prints, where either is at hand. `ktl skills
12
+ status` says, for each folder an agent reads, whether the KTL skills there
13
+ are this package's.
14
+
15
+ The command uses the standard library alone and runs on Python 3.9 or
16
+ later, the python3 that RHEL 9 includes. The verbs are those of the lokf
17
+ toolkit's `lokf skills` command, so a person who uses both meets one shape.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import argparse
23
+ import os
24
+ import shutil
25
+ import stat
26
+ import sys
27
+ import tempfile
28
+ from pathlib import Path
29
+
30
+ # Where each agent reads skills, at project scope (relative to the
31
+ # repository's top folder) and at user scope (relative to the home folder).
32
+ # Each placement was checked against `gh skill install` 2.101. An agent not
33
+ # listed here takes --dest. No one folder serves them all: Claude Code reads
34
+ # only its own, and Copilot and Codex differ at user scope. So, as with `gh
35
+ # skill install`, GitHub Copilot is the agent when none is named.
36
+ AGENTS = {
37
+ "claude-code": (".claude/skills", ".claude/skills"),
38
+ "codex": (".agents/skills", ".agents/skills"),
39
+ "github-copilot": (".agents/skills", ".copilot/skills"),
40
+ }
41
+ DEFAULT_AGENT = "github-copilot"
42
+
43
+ # A project folder Copilot-style agents also read, which no installer here
44
+ # writes. `status` checks it, as the sidecar's preflight does.
45
+ OTHER_PROJECT_FOLDERS = {".github/skills": "github-copilot"}
46
+
47
+ # Files a copy never needs: bytecode a Python run may have left beside a
48
+ # script.
49
+ IGNORED = shutil.ignore_patterns("__pycache__", "*.pyc")
50
+
51
+
52
+ def package_root() -> Path:
53
+ """Return the folder that holds `skills/` and `.claude-plugin/`.
54
+
55
+ In an installed package that is the `ktl` package folder. In a checkout
56
+ of the repository, where the build has not copied the skills in, it is
57
+ the repository root, two folders above `src/ktl/`.
58
+ """
59
+ here = Path(__file__).resolve().parent
60
+ if (here / "skills").is_dir():
61
+ return here
62
+ checkout = here.parent.parent
63
+ if (checkout / "skills" / "ktl-docent" / "SKILL.md").is_file():
64
+ return checkout
65
+ raise SystemExit(f"ktl: no skills folder in {here} or in a checkout above it; reinstall the package")
66
+
67
+
68
+ def skills_dir() -> Path:
69
+ return package_root() / "skills"
70
+
71
+
72
+ def skill_names() -> list[str]:
73
+ return sorted(p.parent.name for p in skills_dir().glob("*/SKILL.md"))
74
+
75
+
76
+ def version() -> str:
77
+ try:
78
+ from importlib.metadata import PackageNotFoundError, version as dist_version
79
+ except ImportError: # pragma: no cover - every supported Python has it
80
+ return "unknown"
81
+ try:
82
+ return dist_version("ktl")
83
+ except PackageNotFoundError:
84
+ return "0.0.0 (a checkout, not an installed package)"
85
+
86
+
87
+ def scalar(text: str) -> str:
88
+ """One YAML scalar written on one line: plain, single-quoted or double-quoted."""
89
+ text = text.strip()
90
+ if len(text) >= 2 and text[0] == text[-1] == "'":
91
+ return text[1:-1].replace("''", "'")
92
+ if len(text) >= 2 and text[0] == text[-1] == '"':
93
+ return text[1:-1].replace('\\"', '"').replace("\\\\", "\\")
94
+ return text
95
+
96
+
97
+ def read_skill_md(path: Path) -> tuple[dict[str, str], dict[str, str], str]:
98
+ """Split a SKILL.md into its frontmatter values, its metadata block and its body.
99
+
100
+ The skills write each value on one line, plain or quoted, so a line
101
+ reader serves and the package needs no YAML parser. `gh skill install`
102
+ rewrites the file as it installs it: the keys come out sorted, a quoted
103
+ value can come out plain, a metadata block names the source, and the
104
+ blank line after the frontmatter goes. Reading each value, and the body
105
+ without its leading blank lines, undoes all four, so two copies of one
106
+ release read the same however each was installed.
107
+ """
108
+ lines = path.read_text(encoding="utf-8").splitlines()
109
+ values: dict[str, str] = {}
110
+ metadata: dict[str, str] = {}
111
+ if not lines or lines[0].strip() != "---":
112
+ return values, metadata, "\n".join(lines).lstrip("\n")
113
+ key = ""
114
+ for number, line in enumerate(lines[1:], start=1):
115
+ if line.strip() == "---":
116
+ return values, metadata, "\n".join(lines[number + 1 :]).lstrip("\n")
117
+ if line[:1] in (" ", "\t"):
118
+ if key == "metadata" and ":" in line:
119
+ name, value = line.split(":", 1)
120
+ metadata[name.strip()] = scalar(value)
121
+ continue
122
+ if ":" in line:
123
+ key, value = line.split(":", 1)
124
+ key = key.strip()
125
+ if key != "metadata":
126
+ values[key] = scalar(value)
127
+ return values, metadata, ""
128
+
129
+
130
+ def first_sentence(text: str) -> str:
131
+ end = text.find(". ")
132
+ return text if end < 0 else text[: end + 1]
133
+
134
+
135
+ def project_root(start: Path) -> Path:
136
+ """Return the top folder of the repository that holds start: the nearest
137
+ folder at or above it with a `.git` entry, which is a folder in a clone
138
+ and a file in a worktree or a submodule. With none, start itself."""
139
+ for folder in (start, *start.parents):
140
+ if (folder / ".git").exists():
141
+ return folder
142
+ return start
143
+
144
+
145
+ def target_folder(agent: str, scope: str) -> Path:
146
+ project, user = AGENTS[agent]
147
+ return (Path.home() / user) if scope == "user" else (project_root(Path.cwd()) / project)
148
+
149
+
150
+ def install(names: list[str], dest: Path) -> None:
151
+ """Copy each named skill folder into dest, replacing a folder of the
152
+ same name.
153
+
154
+ Every skill is copied into one hidden staging folder in dest first, and
155
+ the old folders are swapped for the new ones only once all are copied.
156
+ So a copy that fails, for want of space, say, leaves the earlier release
157
+ whole rather than half replaced. The staging folder holds each skill a
158
+ level down, where no agent looks for one.
159
+ """
160
+ dest.mkdir(parents=True, exist_ok=True)
161
+ source = skills_dir()
162
+ staging = Path(tempfile.mkdtemp(prefix=".ktl-", dir=dest))
163
+ try:
164
+ for name in names:
165
+ shutil.copytree(source / name, staging / name, ignore=IGNORED)
166
+ for name in names:
167
+ target = dest / name
168
+ if target.is_symlink() or target.is_file():
169
+ target.unlink()
170
+ elif target.exists():
171
+ shutil.rmtree(target)
172
+ (staging / name).rename(target)
173
+ finally:
174
+ shutil.rmtree(staging, ignore_errors=True)
175
+
176
+
177
+ def files_of(folder: Path) -> dict[str, Path]:
178
+ """Every file in a skill folder by its path inside it, bytecode aside."""
179
+ return {
180
+ p.relative_to(folder).as_posix(): p
181
+ for p in sorted(folder.rglob("*"))
182
+ if p.is_file() and "__pycache__" not in p.parts and p.suffix != ".pyc"
183
+ }
184
+
185
+
186
+ def executable(path: Path) -> bool:
187
+ return bool(path.stat().st_mode & stat.S_IXUSR)
188
+
189
+
190
+ def same_skill_md(a: Path, b: Path) -> bool:
191
+ """Whether two SKILL.md files read the same, gh's metadata block aside."""
192
+ values_a, _, body_a = read_skill_md(a)
193
+ values_b, _, body_b = read_skill_md(b)
194
+ return values_a == values_b and body_a == body_b
195
+
196
+
197
+ def compare(installed: Path, carried: Path) -> tuple[list[str], list[str]]:
198
+ """Compare an installed skill with this package's copy of it.
199
+
200
+ Return the files whose content differs, or that only one copy holds,
201
+ and the files this package marks executable that the installed copy
202
+ does not. A SKILL.md counts as the same when it reads the same through
203
+ read_skill_md. The skills run their scripts through bash, so a lost
204
+ executable bit is reported apart from a change of content.
205
+ """
206
+ mine, theirs = files_of(carried), files_of(installed)
207
+ changed = sorted(set(mine) ^ set(theirs))
208
+ not_executable = []
209
+ for rel in sorted(set(mine) & set(theirs)):
210
+ a, b = mine[rel], theirs[rel]
211
+ if a.read_bytes() != b.read_bytes() and not (rel == "SKILL.md" and same_skill_md(a, b)):
212
+ changed.append(rel)
213
+ if os.name != "nt" and executable(a) and not executable(b):
214
+ not_executable.append(rel)
215
+ return sorted(changed), not_executable
216
+
217
+
218
+ def where(folder: Path) -> str:
219
+ try:
220
+ return "~/" + folder.relative_to(Path.home()).as_posix()
221
+ except ValueError:
222
+ return str(folder)
223
+
224
+
225
+ def folders_to_check(dest: Path | None) -> dict[Path, list[str]]:
226
+ """Each folder an agent reads skills from, with who reads it: the
227
+ project's folders first, then the user's."""
228
+ if dest is not None:
229
+ return {dest: []}
230
+ root = project_root(Path.cwd())
231
+ readers: dict[Path, list[str]] = {}
232
+ for agent, (project, _) in sorted(AGENTS.items()):
233
+ readers.setdefault(root / project, []).append(f"{agent}, project")
234
+ for folder, agent in OTHER_PROJECT_FOLDERS.items():
235
+ readers.setdefault(root / folder, []).append(f"{agent}, project")
236
+ for agent, (_, user) in sorted(AGENTS.items()):
237
+ readers.setdefault(Path.home() / user, []).append(f"{agent}, user")
238
+ return readers
239
+
240
+
241
+ def state(folder: Path, name: str, carried: list[str]) -> tuple[str, bool]:
242
+ """One installed skill's line in `status`, and whether it matches this package."""
243
+ skill = folder / name
244
+ if name not in carried:
245
+ return "not in this package: an older release's, or another's", False
246
+ changed, not_executable = compare(skill, skills_dir() / name)
247
+ ref = read_skill_md(skill / "SKILL.md")[1].get("github-ref", "")
248
+ source = f"; installed by gh skill from {ref.rsplit('/', 1)[-1]}" if ref else ""
249
+ if changed:
250
+ shown = ", ".join(changed[:3]) + (", ..." if len(changed) > 3 else "")
251
+ count = f"{len(changed)} file{'s' if len(changed) != 1 else ''}"
252
+ return f"differs from this package in {count} ({shown}){source}", False
253
+ lost = f", though {len(not_executable)} script{'s' if len(not_executable) != 1 else ''} lost the executable bit" if not_executable else ""
254
+ return f"same as this package{lost}{source}", True
255
+
256
+
257
+ def cmd_list(args: argparse.Namespace) -> int:
258
+ for name in skill_names():
259
+ description = read_skill_md(skills_dir() / name / "SKILL.md")[0].get("description", "")
260
+ print(f"{name}\t{first_sentence(description)}")
261
+ return 0
262
+
263
+
264
+ def cmd_path(args: argparse.Namespace) -> int:
265
+ print(package_root() if args.marketplace else skills_dir())
266
+ return 0
267
+
268
+
269
+ def cmd_install(args: argparse.Namespace, parser: argparse.ArgumentParser) -> int:
270
+ if args.dest is not None and (args.agent or args.scope):
271
+ parser.error("give --dest, or --scope with an optional --agent, not both")
272
+ if args.dest is None and not args.scope:
273
+ parser.error("name the scope (--scope user|project), or a folder with --dest")
274
+ available = skill_names()
275
+ names = sorted(set(args.skill)) if args.skill else available
276
+ unknown = [n for n in names if n not in available]
277
+ if unknown:
278
+ parser.error(f"no skill named {', '.join(unknown)}; this package carries {', '.join(available)}")
279
+ dest = args.dest if args.dest is not None else target_folder(args.agent or DEFAULT_AGENT, args.scope)
280
+ install(names, dest)
281
+ print(f"Installed {len(names)} skill{'s' if len(names) != 1 else ''} from ktl {version()} into {dest}: {', '.join(names)}")
282
+ return 0
283
+
284
+
285
+ def cmd_status(args: argparse.Namespace) -> int:
286
+ carried = skill_names()
287
+ print(f"ktl {version()} carries {', '.join(carried)}.")
288
+ in_step, empty = True, []
289
+ for folder, readers in folders_to_check(args.dest).items():
290
+ present = sorted(p.parent.name for p in folder.glob("ktl-*/SKILL.md")) if folder.is_dir() else []
291
+ if not present:
292
+ empty.append(where(folder))
293
+ continue
294
+ print(where(folder) + (f" (read by {'; '.join(readers)})" if readers else ""))
295
+ for name in present:
296
+ line, same = state(folder, name, carried)
297
+ in_step = in_step and same
298
+ print(f" {name:<14} {line}")
299
+ absent = [name for name in carried if name not in present]
300
+ if absent:
301
+ print(f" {'not here':<14} {', '.join(absent)}")
302
+ if empty:
303
+ print(f"No KTL skill in {', '.join(empty)}.")
304
+ return 0 if in_step else 1
305
+
306
+
307
+ def build_parser() -> argparse.ArgumentParser:
308
+ parser = argparse.ArgumentParser(
309
+ prog="ktl",
310
+ description="List, locate, install and check the Knowledge Trust Ladder (KTL) skills this package carries, "
311
+ "and set the GitHub settings the workflows they install need.",
312
+ )
313
+ parser.add_argument("--version", action="version", version=f"ktl {version()}")
314
+ groups = parser.add_subparsers(dest="group", metavar="{skills,gh}")
315
+ skills = groups.add_parser("skills", help="the six skills: list (the default), path, install, status")
316
+ actions = skills.add_subparsers(dest="action", metavar="{list,path,install,status}")
317
+
318
+ actions.add_parser("list", help="name each skill, with the first sentence of its description")
319
+
320
+ path = actions.add_parser("path", help="print the folder that holds the skills")
321
+ path.add_argument(
322
+ "--marketplace",
323
+ action="store_true",
324
+ help="print the folder that holds .claude-plugin/ instead, for `claude plugin marketplace add`",
325
+ )
326
+
327
+ table = "\n".join(
328
+ f" {agent:<15} project: {project}/ user: ~/{user}/" for agent, (project, user) in sorted(AGENTS.items())
329
+ )
330
+ install_parser = actions.add_parser(
331
+ "install",
332
+ help="copy the skills into the folder an agent reads",
333
+ formatter_class=argparse.RawDescriptionHelpFormatter,
334
+ description="Copy the skills into the folder an agent reads, replacing any earlier copy of each.",
335
+ epilog=f"Where each agent reads skills (project scope is relative to the repository's top folder):\n{table}\n\n"
336
+ f"With no --agent, the agent is {DEFAULT_AGENT}. Give --dest for any other agent or folder.",
337
+ )
338
+ install_parser.add_argument(
339
+ "--agent", choices=sorted(AGENTS), help=f"the agent whose folder to use (default: {DEFAULT_AGENT})"
340
+ )
341
+ install_parser.add_argument("--scope", choices=["user", "project"], help="the user's folder, or this project's")
342
+ install_parser.add_argument("--dest", type=Path, help="install into this folder instead")
343
+ install_parser.add_argument(
344
+ "--skill",
345
+ action="append",
346
+ metavar="NAME",
347
+ help="install only this skill; repeat for more (default: all six)",
348
+ )
349
+
350
+ status = actions.add_parser(
351
+ "status",
352
+ help="say whether the KTL skills in each folder an agent reads are this package's",
353
+ description="For each folder an agent reads skills from, at project and user scope, say whether each KTL "
354
+ "skill there is the same as this package's copy. A copy gh skill installed counts as the same when only "
355
+ "gh's own rewrite of its SKILL.md differs. Exits 1 when an installed copy differs, so a script can act on it.",
356
+ )
357
+ status.add_argument("--dest", type=Path, help="check this folder only")
358
+
359
+ from ktl import gh
360
+
361
+ gh.add_parser(groups)
362
+ return parser
363
+
364
+
365
+ def main(argv: list[str] | None = None) -> int:
366
+ parser = build_parser()
367
+ args = parser.parse_args(argv)
368
+ if args.group == "gh":
369
+ from ktl import gh
370
+
371
+ return gh.main(args)
372
+ if args.group != "skills":
373
+ parser.print_help()
374
+ return 2
375
+ try:
376
+ if args.action in (None, "list"):
377
+ return cmd_list(args)
378
+ if args.action == "path":
379
+ return cmd_path(args)
380
+ if args.action == "status":
381
+ return cmd_status(args)
382
+ return cmd_install(args, parser)
383
+ except OSError as error:
384
+ reason = error.strerror or str(error)
385
+ print(f"ktl: {reason}: {error.filename}" if error.filename else f"ktl: {reason}", file=sys.stderr)
386
+ return 1
387
+
388
+
389
+ if __name__ == "__main__": # pragma: no cover
390
+ sys.exit(main())