logion-cli 0.1.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 (135) hide show
  1. cli/__init__.py +2 -0
  2. cli/_config.py +51 -0
  3. cli/_confirm.py +16 -0
  4. cli/_context.py +17 -0
  5. cli/_course_bundle.py +46 -0
  6. cli/_course_capabilities.py +580 -0
  7. cli/_credentials.py +104 -0
  8. cli/_errors.py +82 -0
  9. cli/_first_run.py +90 -0
  10. cli/_harness/__init__.py +68 -0
  11. cli/_harness/base.py +106 -0
  12. cli/_harness/claude_code.py +168 -0
  13. cli/_harness/codex.py +79 -0
  14. cli/_harness/custom.py +55 -0
  15. cli/_harness/hermes.py +93 -0
  16. cli/_harness/opencode.py +255 -0
  17. cli/_local_state.py +1053 -0
  18. cli/_options.py +36 -0
  19. cli/_output.py +47 -0
  20. cli/_parser.py +73 -0
  21. cli/_recall_calibration.py +90 -0
  22. cli/_recall_ranker.py +74 -0
  23. cli/_taxonomy.py +120 -0
  24. cli/_update_policy.py +152 -0
  25. cli/_utils.py +16 -0
  26. cli/_version.py +26 -0
  27. cli/commands/__init__.py +2 -0
  28. cli/commands/admin.py +535 -0
  29. cli/commands/bounties.py +490 -0
  30. cli/commands/course_reviews/__init__.py +6 -0
  31. cli/commands/course_reviews/_download_handler.py +104 -0
  32. cli/commands/course_reviews/_render.py +129 -0
  33. cli/commands/course_reviews/handlers.py +197 -0
  34. cli/commands/course_reviews/parser.py +93 -0
  35. cli/commands/courses/__init__.py +6 -0
  36. cli/commands/courses/_capability_render.py +183 -0
  37. cli/commands/courses/_cmd_help.py +18 -0
  38. cli/commands/courses/_purchase.py +76 -0
  39. cli/commands/courses/_review_helpers.py +93 -0
  40. cli/commands/courses/_taxonomy_data.py +173 -0
  41. cli/commands/courses/_upload_bundle_validation.py +28 -0
  42. cli/commands/courses/_uploads_push.py +243 -0
  43. cli/commands/courses/capabilities.py +250 -0
  44. cli/commands/courses/capability_frontmatter.py +150 -0
  45. cli/commands/courses/handlers.py +50 -0
  46. cli/commands/courses/mutations.py +217 -0
  47. cli/commands/courses/parser.py +66 -0
  48. cli/commands/courses/parser_capabilities.py +95 -0
  49. cli/commands/courses/parser_sections.py +239 -0
  50. cli/commands/courses/parser_uploads.py +84 -0
  51. cli/commands/courses/parser_utils.py +65 -0
  52. cli/commands/courses/publication.py +60 -0
  53. cli/commands/courses/report_usage.py +131 -0
  54. cli/commands/courses/reviews.py +237 -0
  55. cli/commands/courses/taxonomy_handler.py +61 -0
  56. cli/commands/courses/taxonomy_suggest.py +197 -0
  57. cli/commands/courses/uploads.py +142 -0
  58. cli/commands/courses/versions.py +65 -0
  59. cli/commands/credits/__init__.py +6 -0
  60. cli/commands/credits/_helpers.py +153 -0
  61. cli/commands/credits/handlers.py +218 -0
  62. cli/commands/credits/parser.py +115 -0
  63. cli/commands/docs/__init__.py +6 -0
  64. cli/commands/docs/handlers.py +137 -0
  65. cli/commands/docs/parser.py +27 -0
  66. cli/commands/health/__init__.py +6 -0
  67. cli/commands/health/handlers.py +26 -0
  68. cli/commands/health/parser.py +20 -0
  69. cli/commands/identity/__init__.py +6 -0
  70. cli/commands/identity/_autopost.py +97 -0
  71. cli/commands/identity/_closing_copy.py +89 -0
  72. cli/commands/identity/_companion.py +232 -0
  73. cli/commands/identity/_companion_source.py +135 -0
  74. cli/commands/identity/_harness_select.py +85 -0
  75. cli/commands/identity/_onboarding_helpers.py +168 -0
  76. cli/commands/identity/handlers.py +173 -0
  77. cli/commands/identity/onboarding.py +246 -0
  78. cli/commands/identity/parser.py +72 -0
  79. cli/commands/listings/__init__.py +6 -0
  80. cli/commands/listings/handlers.py +135 -0
  81. cli/commands/listings/parser.py +57 -0
  82. cli/commands/notifications/__init__.py +6 -0
  83. cli/commands/notifications/handlers.py +120 -0
  84. cli/commands/notifications/parser.py +49 -0
  85. cli/commands/payments/__init__.py +6 -0
  86. cli/commands/payments/_orders_helpers.py +114 -0
  87. cli/commands/payments/handlers.py +138 -0
  88. cli/commands/payments/parser.py +97 -0
  89. cli/commands/recall/__init__.py +7 -0
  90. cli/commands/recall/handlers.py +87 -0
  91. cli/commands/recall/parser.py +70 -0
  92. cli/commands/referrals/__init__.py +6 -0
  93. cli/commands/referrals/_helpers.py +63 -0
  94. cli/commands/referrals/handlers.py +100 -0
  95. cli/commands/referrals/parser.py +65 -0
  96. cli/commands/reports/__init__.py +6 -0
  97. cli/commands/reports/handlers.py +57 -0
  98. cli/commands/reports/parser.py +52 -0
  99. cli/commands/skills/__init__.py +7 -0
  100. cli/commands/skills/_agent_symlink.py +161 -0
  101. cli/commands/skills/_finalize.py +112 -0
  102. cli/commands/skills/_inspect_handler.py +218 -0
  103. cli/commands/skills/_install_helpers.py +186 -0
  104. cli/commands/skills/_query_handlers.py +83 -0
  105. cli/commands/skills/_search_handler.py +136 -0
  106. cli/commands/skills/_update_handler.py +110 -0
  107. cli/commands/skills/_verify_handler.py +109 -0
  108. cli/commands/skills/handlers.py +202 -0
  109. cli/commands/skills/parser.py +154 -0
  110. cli/commands/workspace.py +406 -0
  111. cli/docs/README.md +5 -0
  112. cli/docs/__init__.py +1 -0
  113. cli/docs/bounties-and-referrals.md +18 -0
  114. cli/docs/concepts.md +47 -0
  115. cli/docs/creating-courses.md +25 -0
  116. cli/docs/credits-and-purchases.md +30 -0
  117. cli/docs/credits-terms.md +23 -0
  118. cli/docs/getting-started.md +95 -0
  119. cli/docs/marketplace-loop.md +108 -0
  120. cli/docs/privacy.md +30 -0
  121. cli/docs/referral-terms.md +24 -0
  122. cli/docs/reviews.md +47 -0
  123. cli/docs/safety.md +28 -0
  124. cli/docs/terms.md +54 -0
  125. cli/main.py +84 -0
  126. cli/templates/__init__.py +2 -0
  127. cli/templates/course_capabilities.template.yaml +189 -0
  128. cli/templates/course_license_apache-2.0.template.txt +30 -0
  129. cli/templates/course_license_logion-standard-course-v1.template.txt +49 -0
  130. cli/templates/course_license_mit.template.txt +21 -0
  131. logion_cli-0.1.0.dist-info/METADATA +49 -0
  132. logion_cli-0.1.0.dist-info/RECORD +135 -0
  133. logion_cli-0.1.0.dist-info/WHEEL +4 -0
  134. logion_cli-0.1.0.dist-info/entry_points.txt +4 -0
  135. logion_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,131 @@
1
+ # SPDX-License-Identifier: MIT
2
+ """Agent-driven review convenience command."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+
8
+ from cli._config import resolve_config_from_args
9
+ from cli._context import make_client
10
+ from cli._errors import handle_error, validate_uuid_id
11
+ from cli._output import emit, emit_json
12
+ from cli._utils import only_not_none
13
+
14
+ from .reviews import _validate_review_scores
15
+
16
+
17
+ def handle_report_usage(args: argparse.Namespace) -> int:
18
+ """Execute the courses report-usage command.
19
+
20
+ Thin convenience wrapper over ``reviews upsert`` for the agent-driven
21
+ review loop. Same guardrails (entitlement-required, self-review-blocked)
22
+ apply because the call is forwarded to the same API endpoint.
23
+ """
24
+ bad_id = validate_uuid_id(args.course_id, "COURSE_ID")
25
+ if bad_id is not None:
26
+ return bad_id
27
+ bad_id = validate_uuid_id(args.version_id, "VERSION_ID")
28
+ if bad_id is not None:
29
+ return bad_id
30
+ review_scores_error = _validate_review_scores(args)
31
+ if review_scores_error is not None:
32
+ return review_scores_error
33
+
34
+ config = resolve_config_from_args(args)
35
+ client = make_client(config)
36
+ try:
37
+ kwargs = only_not_none(
38
+ {
39
+ "course_id": args.course_id,
40
+ "version_id": args.version_id,
41
+ "rating": args.rating,
42
+ },
43
+ body=args.body,
44
+ completed_task=args.completed_task,
45
+ reliability=args.reliability,
46
+ usefulness=args.usefulness,
47
+ tool_safety=args.tool_safety,
48
+ token_efficiency=args.token_efficiency,
49
+ )
50
+ result = client.v1.courses.review_version(**kwargs)
51
+ from cli.commands.courses._review_helpers import (
52
+ data_or_model_dump,
53
+ )
54
+
55
+ data = data_or_model_dump(result)
56
+ if config.json_output:
57
+ emit_json("logion.courses.report-usage", data)
58
+ else:
59
+ emit(result, json_output=False)
60
+ except Exception as exc:
61
+ return handle_error(exc)
62
+ else:
63
+ return 0
64
+ finally:
65
+ client.close()
66
+
67
+
68
+ def register_report_usage(
69
+ subparsers: argparse._SubParsersAction,
70
+ ) -> None:
71
+ """Register the ``courses report-usage`` subcommand."""
72
+ from cli._options import COMMON_PARSER
73
+
74
+ parser = subparsers.add_parser(
75
+ "report-usage",
76
+ help="File a usage review after completing a course-driven task",
77
+ parents=[COMMON_PARSER],
78
+ )
79
+ parser.add_argument("course_id", metavar="COURSE_ID")
80
+ parser.add_argument("version_id", metavar="VERSION_ID")
81
+ parser.add_argument(
82
+ "--rating",
83
+ type=int,
84
+ required=True,
85
+ help="Overall rating 1-5 (required).",
86
+ )
87
+ parser.add_argument(
88
+ "--body",
89
+ default=None,
90
+ help="Short narrative: what worked, what didn't.",
91
+ )
92
+ completed = parser.add_mutually_exclusive_group()
93
+ completed.add_argument(
94
+ "--completed-task",
95
+ action="store_true",
96
+ default=None,
97
+ dest="completed_task",
98
+ help="The task finished successfully.",
99
+ )
100
+ completed.add_argument(
101
+ "--not-completed-task",
102
+ action="store_false",
103
+ default=None,
104
+ dest="completed_task",
105
+ help="The task did not finish.",
106
+ )
107
+ parser.add_argument(
108
+ "--reliability",
109
+ type=float,
110
+ default=None,
111
+ help="Did the course work without surprises? 0.0-5.0.",
112
+ )
113
+ parser.add_argument(
114
+ "--usefulness",
115
+ type=float,
116
+ default=None,
117
+ help="Did the course content help with the task? 0.0-5.0.",
118
+ )
119
+ parser.add_argument(
120
+ "--tool-safety",
121
+ type=float,
122
+ default=None,
123
+ help="Did it stay within declared capabilities? 0.0-5.0.",
124
+ )
125
+ parser.add_argument(
126
+ "--token-efficiency",
127
+ type=float,
128
+ default=None,
129
+ help="Subjective token cost impression. 0.0-5.0.",
130
+ )
131
+ parser.set_defaults(handler=handle_report_usage)
@@ -0,0 +1,237 @@
1
+ # SPDX-License-Identifier: MIT
2
+ """Review handlers for courses commands."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import sys
8
+
9
+ from cli._config import resolve_config_from_args
10
+ from cli._context import make_client
11
+ from cli._errors import (
12
+ handle_error,
13
+ print_err,
14
+ validate_uuid_id,
15
+ )
16
+ from cli._output import emit, emit_json, to_data
17
+ from cli._utils import only_not_none
18
+ from cli.commands.courses._capability_render import (
19
+ append_capability_feedback_lines,
20
+ )
21
+ from cli.commands.courses._review_helpers import (
22
+ collect_reviews,
23
+ compact_review,
24
+ compute_summary,
25
+ data_or_model_dump,
26
+ )
27
+
28
+ REVIEW_SCORE_FIELDS = [
29
+ ("reliability", "--reliability"),
30
+ ("usefulness", "--usefulness"),
31
+ ("tool_safety", "--tool-safety"),
32
+ ("token_efficiency", "--token-efficiency"),
33
+ ]
34
+
35
+ _DEFAULT_LIST_LIMIT = 5
36
+ _MAX_LIST_LIMIT = 50
37
+
38
+
39
+ def _validate_review_scores(args: argparse.Namespace) -> int | None:
40
+ if not (1 <= args.rating <= 5):
41
+ print_err("Error: --rating must be between 1 and 5.")
42
+ return 2
43
+ for attr, label in REVIEW_SCORE_FIELDS:
44
+ value = getattr(args, attr)
45
+ if value is not None and not (0.0 <= value <= 5.0):
46
+ print_err(f"Error: {label} must be between 0.0 and 5.0.")
47
+ return 2
48
+ return None
49
+
50
+
51
+ def handle_reviews_list(args: argparse.Namespace) -> int:
52
+ """Execute the courses reviews list command."""
53
+ bad_id = validate_uuid_id(args.course_id, "COURSE_ID")
54
+ if bad_id is not None:
55
+ return bad_id
56
+ config = resolve_config_from_args(args)
57
+ client = make_client(config)
58
+ try:
59
+ limit = min(
60
+ max(args.limit or _DEFAULT_LIST_LIMIT, 1),
61
+ _MAX_LIST_LIMIT,
62
+ )
63
+ kwargs = only_not_none(
64
+ {"course_id": args.course_id},
65
+ version=args.version,
66
+ limit=limit,
67
+ cursor=args.cursor,
68
+ )
69
+ result = client.v1.courses.list_reviews(**kwargs)
70
+ if config.json_output:
71
+ data = data_or_model_dump(result)
72
+ reviews = [
73
+ compact_review(review)
74
+ for review in data.get("reviews", [])
75
+ if isinstance(review, dict)
76
+ ]
77
+ emit_json(
78
+ "logion.courses.reviews.list",
79
+ {
80
+ "items": reviews,
81
+ "limit": kwargs["limit"],
82
+ "next_cursor": data.get("next_cursor"),
83
+ },
84
+ )
85
+ else:
86
+ emit(result, json_output=False)
87
+ except Exception as exc:
88
+ return handle_error(exc)
89
+ else:
90
+ return 0
91
+ finally:
92
+ client.close()
93
+
94
+
95
+ def handle_reviews_mine(args: argparse.Namespace) -> int:
96
+ """Execute the courses reviews mine command."""
97
+ bad_id = validate_uuid_id(args.course_id, "COURSE_ID")
98
+ if bad_id is not None:
99
+ return bad_id
100
+ if args.version_id is not None:
101
+ bad_id = validate_uuid_id(args.version_id, "--version-id")
102
+ if bad_id is not None:
103
+ return bad_id
104
+ config = resolve_config_from_args(args)
105
+ client = make_client(config)
106
+ try:
107
+ kwargs = only_not_none(
108
+ {"course_id": args.course_id},
109
+ version_id=args.version_id,
110
+ )
111
+ result = client.v1.courses.get_my_review(**kwargs)
112
+ if config.json_output:
113
+ data = data_or_model_dump(result)
114
+ emit_json("logion.courses.reviews.mine", data)
115
+ else:
116
+ emit(result, json_output=False)
117
+ except Exception as exc:
118
+ return handle_error(exc)
119
+ else:
120
+ return 0
121
+ finally:
122
+ client.close()
123
+
124
+
125
+ def handle_reviews_upsert(args: argparse.Namespace) -> int:
126
+ """Execute the courses reviews upsert command."""
127
+ bad_id = validate_uuid_id(args.course_id, "COURSE_ID")
128
+ if bad_id is not None:
129
+ return bad_id
130
+ bad_id = validate_uuid_id(args.version_id, "VERSION_ID")
131
+ if bad_id is not None:
132
+ return bad_id
133
+ review_scores_error = _validate_review_scores(args)
134
+ if review_scores_error is not None:
135
+ return review_scores_error
136
+ config = resolve_config_from_args(args)
137
+ client = make_client(config)
138
+ try:
139
+ kwargs = only_not_none(
140
+ {
141
+ "course_id": args.course_id,
142
+ "version_id": args.version_id,
143
+ "rating": args.rating,
144
+ },
145
+ body=args.body,
146
+ completed_task=args.completed_task,
147
+ reliability=args.reliability,
148
+ usefulness=args.usefulness,
149
+ tool_safety=args.tool_safety,
150
+ token_efficiency=args.token_efficiency,
151
+ )
152
+ result = client.v1.courses.review_version(**kwargs)
153
+ if config.json_output:
154
+ data = data_or_model_dump(result)
155
+ emit_json("logion.courses.reviews.upsert", data)
156
+ else:
157
+ emit(result, json_output=False)
158
+ except Exception as exc:
159
+ return handle_error(exc)
160
+ else:
161
+ return 0
162
+ finally:
163
+ client.close()
164
+
165
+
166
+ def handle_reviews_summary(args: argparse.Namespace) -> int:
167
+ """Compute aggregate review statistics for a course."""
168
+ bad_id = validate_uuid_id(args.course_id, "COURSE_ID")
169
+ if bad_id is not None:
170
+ return bad_id
171
+ config = resolve_config_from_args(args)
172
+ client = make_client(config)
173
+ try:
174
+ limit = min(
175
+ max(args.limit or _DEFAULT_LIST_LIMIT, 1),
176
+ _MAX_LIST_LIMIT,
177
+ )
178
+ all_reviews = collect_reviews(
179
+ client,
180
+ args.course_id,
181
+ version=getattr(args, "version", None),
182
+ limit=limit,
183
+ )
184
+ summary = compute_summary(args.course_id, all_reviews)
185
+ if config.json_output:
186
+ emit_json("logion.courses.reviews.summary", summary)
187
+ else:
188
+ lines: list[str] = [
189
+ f"course_id: {summary['course_id']}",
190
+ f"review_count: {summary['review_count']}",
191
+ f"rating_avg: {summary['rating_avg']}",
192
+ f"rating_histogram: {summary['rating_histogram']}",
193
+ ]
194
+ sys.stdout.write("\n".join(lines))
195
+ sys.stdout.write("\n")
196
+ except Exception as exc:
197
+ return handle_error(exc)
198
+ else:
199
+ return 0
200
+ finally:
201
+ client.close()
202
+
203
+
204
+ def handle_feedback(args: argparse.Namespace) -> int:
205
+ """Execute the courses feedback command."""
206
+ bad_id = validate_uuid_id(args.course_id, "COURSE_ID")
207
+ if bad_id is not None:
208
+ return bad_id
209
+ config = resolve_config_from_args(args)
210
+ client = make_client(config)
211
+ try:
212
+ result = client.v1.courses.get_review_feedback(
213
+ course_id=args.course_id,
214
+ )
215
+ if config.json_output:
216
+ data = data_or_model_dump(result)
217
+ emit_json("logion.courses.feedback", data)
218
+ else:
219
+ data = to_data(result)
220
+ lines: list[str] = []
221
+ if data.get("summary"):
222
+ lines.append(f"summary: {data['summary']}")
223
+ if data.get("findings"):
224
+ lines.append("findings:")
225
+ for f in data["findings"]:
226
+ lines.append(f" - {f}")
227
+ append_capability_feedback_lines(lines, data)
228
+ if not lines:
229
+ lines.append("No feedback available.")
230
+ sys.stdout.write("\n".join(lines))
231
+ sys.stdout.write("\n")
232
+ except Exception as exc:
233
+ return handle_error(exc)
234
+ else:
235
+ return 0
236
+ finally:
237
+ client.close()
@@ -0,0 +1,61 @@
1
+ # SPDX-License-Identifier: MIT
2
+ """Handler and parser registration for ``courses taxonomy`` commands."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ from pathlib import Path
8
+
9
+ from cli._config import resolve_config_from_args
10
+ from cli._options import COMMON_PARSER
11
+ from cli._output import emit_json
12
+
13
+ from .taxonomy_suggest import suggest_taxonomy
14
+
15
+
16
+ def handle_taxonomy_suggest(args: argparse.Namespace) -> int:
17
+ """Produce deterministic category/tag suggestions for a local bundle."""
18
+ config = resolve_config_from_args(args)
19
+ bundle_dir: Path = args.bundle_dir
20
+ result = suggest_taxonomy(bundle_dir)
21
+ if config.json_output:
22
+ emit_json("logion.courses.taxonomy.suggest", result)
23
+ else:
24
+ lines: list[str] = []
25
+ cats = result["category_suggestions"]
26
+ lines.append(f"category_suggestions: {', '.join(cats)}")
27
+ tags = result["tag_suggestions"]
28
+ lines.append(f"tag_suggestions: {', '.join(tags)}")
29
+ rejected = result["rejected_reserved"]
30
+ if rejected:
31
+ lines.append(f"rejected_reserved: {', '.join(rejected)}")
32
+ lines.append(f"source: {', '.join(result['source'])}")
33
+ print("\n".join(lines))
34
+ return 0
35
+
36
+
37
+ def register_taxonomy(
38
+ subparsers: argparse._SubParsersAction,
39
+ ) -> None:
40
+ """Register the ``courses taxonomy`` subcommand group."""
41
+ taxonomy = subparsers.add_parser(
42
+ "taxonomy",
43
+ help="Suggest category and tags from local course bundle text",
44
+ )
45
+ taxonomy_sub = taxonomy.add_subparsers(
46
+ dest="courses_taxonomy_command",
47
+ required=True,
48
+ )
49
+
50
+ suggest = taxonomy_sub.add_parser(
51
+ "suggest",
52
+ help="Suggest category and tags from SKILL.md and capabilities.yaml",
53
+ parents=[COMMON_PARSER],
54
+ )
55
+ suggest.add_argument(
56
+ "--bundle-dir",
57
+ type=Path,
58
+ required=True,
59
+ help="Path to the course bundle directory",
60
+ )
61
+ suggest.set_defaults(handler=handle_taxonomy_suggest)
@@ -0,0 +1,197 @@
1
+ # SPDX-License-Identifier: MIT
2
+ """Deterministic taxonomy suggestions from local course bundle text.
3
+
4
+ Reads SKILL.md frontmatter and body plus an optional
5
+ ``course/capabilities.yaml`` summary, then maps keywords to category
6
+ slugs and tag slugs. No LLM calls — the mapping is deterministic so
7
+ agents get reproducible suggestions.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import re
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ import yaml
17
+
18
+ from cli._taxonomy import (
19
+ RESERVED_TAG_SLUGS,
20
+ TaxonomyValidationError,
21
+ normalize_tag,
22
+ )
23
+ from cli.commands.courses._taxonomy_data import (
24
+ CATEGORY_KEYWORDS,
25
+ STOP_WORDS,
26
+ )
27
+
28
+ _SKILL_BODY_READ_BYTES = 4096
29
+ _MIN_TOKEN_LENGTH = 3
30
+
31
+
32
+ def _read_skill_frontmatter(skill_path: Path) -> dict[str, Any] | None:
33
+ """Parse YAML frontmatter from SKILL.md, returning None if absent."""
34
+ if not skill_path.is_file():
35
+ return None
36
+ try:
37
+ text = skill_path.read_text(encoding="utf-8")
38
+ except OSError:
39
+ return None
40
+ if not text.startswith("---"):
41
+ return None
42
+ end = text.find("\n---\n", 3)
43
+ if end < 0:
44
+ stripped = text.rstrip()
45
+ if stripped.endswith("---") and text.count("---") >= 2:
46
+ end = stripped.rfind("---")
47
+ block = text[3:end].rstrip()
48
+ else:
49
+ return None
50
+ else:
51
+ block = text[3:end]
52
+ try:
53
+ parsed = yaml.safe_load(block)
54
+ except yaml.YAMLError:
55
+ return None
56
+ return parsed if isinstance(parsed, dict) else None
57
+
58
+
59
+ def _read_skill_body_head(skill_path: Path) -> str:
60
+ """Return the first 4 KB of SKILL.md body (after frontmatter)."""
61
+ if not skill_path.is_file():
62
+ return ""
63
+ try:
64
+ text = skill_path.read_text(encoding="utf-8")
65
+ except OSError:
66
+ return ""
67
+ if text.startswith("---"):
68
+ end = text.find("\n---\n", 3)
69
+ body = text[end + 5 :] if end >= 0 else ""
70
+ else:
71
+ body = text
72
+ return body[:_SKILL_BODY_READ_BYTES]
73
+
74
+
75
+ def _read_capabilities_summary(bundle_dir: Path) -> str:
76
+ """Return a text blob from ``course/capabilities.yaml`` if present."""
77
+ cap_path = bundle_dir / "course" / "capabilities.yaml"
78
+ if not cap_path.is_file():
79
+ return ""
80
+ try:
81
+ data = yaml.safe_load(cap_path.read_text(encoding="utf-8"))
82
+ except (OSError, yaml.YAMLError):
83
+ return ""
84
+ if not isinstance(data, dict):
85
+ return ""
86
+ parts: list[str] = []
87
+ summary = data.get("summary")
88
+ if isinstance(summary, str):
89
+ parts.append(summary)
90
+ tools = data.get("tools")
91
+ if isinstance(tools, list):
92
+ parts.extend(str(t) for t in tools)
93
+ return " ".join(parts)
94
+
95
+
96
+ def _tokenize(text: str) -> list[str]:
97
+ """Lowercase, split on non-alphanumeric, drop stops and short tokens."""
98
+ raw = re.findall(r"[a-zA-Z0-9_-]+", text.lower())
99
+ tokens: list[str] = []
100
+ for word in raw:
101
+ for segment in word.split("-"):
102
+ segment = segment.strip("-")
103
+ if len(segment) >= _MIN_TOKEN_LENGTH and segment not in STOP_WORDS:
104
+ tokens.append(segment)
105
+ if len(word) >= _MIN_TOKEN_LENGTH and word not in STOP_WORDS:
106
+ tokens.append(word)
107
+ return tokens
108
+
109
+
110
+ def _suggest_categories(tokens: list[str]) -> list[str]:
111
+ """Map tokens to category slugs by keyword overlap."""
112
+ token_set = set(tokens)
113
+ matches: list[str] = []
114
+ for category, keywords in CATEGORY_KEYWORDS.items():
115
+ if token_set & keywords:
116
+ matches.append(category)
117
+ if not matches:
118
+ return ["other"]
119
+ # CATEGORY_SLUGS is a frozenset (unordered); iterate CATEGORY_KEYWORDS
120
+ # keys instead so the output order is deterministic across processes.
121
+ match_set = set(matches)
122
+ return [c for c in CATEGORY_KEYWORDS if c in match_set]
123
+
124
+
125
+ def _suggest_tags(tokens: list[str]) -> tuple[list[str], list[str]]:
126
+ """Convert tokens to valid tag slugs, rejecting reserved labels.
127
+
128
+ Returns ``(tags, rejected_reserved)``.
129
+ """
130
+ seen: set[str] = set()
131
+ tags: list[str] = []
132
+ rejected: list[str] = []
133
+ for token in tokens:
134
+ # Check reserved labels before normalize_tag, which raises on
135
+ # reserved slugs — we want to report them, not silently drop.
136
+ normalized = token.strip().lower().replace(" ", "-").replace("_", "-")
137
+ if normalized in RESERVED_TAG_SLUGS:
138
+ if normalized not in seen:
139
+ seen.add(normalized)
140
+ rejected.append(normalized)
141
+ continue
142
+ try:
143
+ tag = normalize_tag(token)
144
+ except TaxonomyValidationError:
145
+ continue
146
+ if tag in seen:
147
+ continue
148
+ seen.add(tag)
149
+ tags.append(tag)
150
+ return tags[:20], rejected
151
+
152
+
153
+ def suggest_taxonomy(
154
+ bundle_dir: Path,
155
+ ) -> dict[str, Any]:
156
+ """Produce deterministic category and tag suggestions for a bundle.
157
+
158
+ Reads ``SKILL.md`` frontmatter (name, description) and body, plus
159
+ ``course/capabilities.yaml`` summary/tools. No network or LLM calls.
160
+ """
161
+ sources: list[str] = []
162
+ skill_path = bundle_dir / "SKILL.md"
163
+ text_parts: list[str] = []
164
+
165
+ frontmatter = _read_skill_frontmatter(skill_path)
166
+ if frontmatter is not None:
167
+ sources.append("SKILL.md")
168
+ name = frontmatter.get("name")
169
+ description = frontmatter.get("description")
170
+ if isinstance(name, str):
171
+ text_parts.append(name)
172
+ if isinstance(description, str):
173
+ text_parts.append(description)
174
+
175
+ body_head = _read_skill_body_head(skill_path)
176
+ if body_head:
177
+ if "SKILL.md" not in sources:
178
+ sources.append("SKILL.md")
179
+ text_parts.append(body_head)
180
+
181
+ cap_summary = _read_capabilities_summary(bundle_dir)
182
+ if cap_summary:
183
+ sources.append("course/capabilities.yaml")
184
+ text_parts.append(cap_summary)
185
+
186
+ combined = " ".join(text_parts)
187
+ tokens = _tokenize(combined)
188
+
189
+ category_suggestions = _suggest_categories(tokens)
190
+ tag_suggestions, rejected_reserved = _suggest_tags(tokens)
191
+
192
+ return {
193
+ "category_suggestions": category_suggestions,
194
+ "tag_suggestions": tag_suggestions,
195
+ "rejected_reserved": rejected_reserved,
196
+ "source": sources,
197
+ }