hoja-cli 0.21.4__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 (107) hide show
  1. VERSION +1 -0
  2. google_workspace/__init__.py +19 -0
  3. google_workspace/client.py +253 -0
  4. google_workspace/config.py +39 -0
  5. google_workspace/errors.py +19 -0
  6. google_workspace/mcp_server.py +100 -0
  7. google_workspace/oauth.py +131 -0
  8. google_workspace/principal.py +83 -0
  9. google_workspace/py.typed +0 -0
  10. google_workspace/tools.py +64 -0
  11. google_workspace/writes.py +44 -0
  12. hoja/__init__.py +29 -0
  13. hoja/__main__.py +6 -0
  14. hoja/auth.py +42 -0
  15. hoja/cli.py +161 -0
  16. hoja/cli_state.py +55 -0
  17. hoja/client.py +268 -0
  18. hoja/commands/__init__.py +4 -0
  19. hoja/commands/auth_cmd.py +271 -0
  20. hoja/commands/comment.py +149 -0
  21. hoja/commands/cycle.py +75 -0
  22. hoja/commands/init.py +108 -0
  23. hoja/commands/issue.py +398 -0
  24. hoja/commands/label.py +163 -0
  25. hoja/commands/memory.py +240 -0
  26. hoja/commands/project.py +56 -0
  27. hoja/commands/raw.py +50 -0
  28. hoja/commands/team.py +102 -0
  29. hoja/commands/update.py +162 -0
  30. hoja/config.py +362 -0
  31. hoja/errors.py +107 -0
  32. hoja/filters.py +150 -0
  33. hoja/graphql/__init__.py +5 -0
  34. hoja/graphql/fragments.py +138 -0
  35. hoja/graphql/mutations.py +160 -0
  36. hoja/graphql/queries.py +245 -0
  37. hoja/mcp/__init__.py +27 -0
  38. hoja/mcp/content.py +151 -0
  39. hoja/mcp/prompts.py +298 -0
  40. hoja/mcp/resources.py +213 -0
  41. hoja/mcp/server.py +189 -0
  42. hoja/memory_board.py +370 -0
  43. hoja/models/__init__.py +37 -0
  44. hoja/models/base.py +64 -0
  45. hoja/models/cycle.py +32 -0
  46. hoja/models/enums.py +62 -0
  47. hoja/models/issue.py +99 -0
  48. hoja/models/label.py +17 -0
  49. hoja/models/project.py +60 -0
  50. hoja/models/team.py +31 -0
  51. hoja/models/user.py +32 -0
  52. hoja/models/workflow.py +65 -0
  53. hoja/output.py +410 -0
  54. hoja/skill_content/commands.md +222 -0
  55. hoja/skill_content/examples.md +193 -0
  56. hoja/skill_content/filters.md +101 -0
  57. hoja/skill_content/output-formats.md +92 -0
  58. hoja/skill_content/overview.md +123 -0
  59. hoja/skill_content/workflows.md +174 -0
  60. hoja_cli-0.21.4.dist-info/METADATA +279 -0
  61. hoja_cli-0.21.4.dist-info/RECORD +107 -0
  62. hoja_cli-0.21.4.dist-info/WHEEL +4 -0
  63. hoja_cli-0.21.4.dist-info/entry_points.txt +4 -0
  64. hoja_cli-0.21.4.dist-info/licenses/LICENSE +21 -0
  65. hoja_server/README.md +135 -0
  66. hoja_server/__init__.py +11 -0
  67. hoja_server/abac.py +116 -0
  68. hoja_server/agent_chat.py +280 -0
  69. hoja_server/agent_conversations.py +91 -0
  70. hoja_server/agent_durable.py +332 -0
  71. hoja_server/agent_prompt.py +117 -0
  72. hoja_server/agent_threads.py +96 -0
  73. hoja_server/agent_uploads.py +60 -0
  74. hoja_server/app.py +2189 -0
  75. hoja_server/cadence.py +231 -0
  76. hoja_server/cli.py +191 -0
  77. hoja_server/cloudflare_realtime.py +110 -0
  78. hoja_server/comment_policy.py +70 -0
  79. hoja_server/connector_actions.py +119 -0
  80. hoja_server/connectors_api.py +375 -0
  81. hoja_server/db.py +1816 -0
  82. hoja_server/denials.py +18 -0
  83. hoja_server/deployment.py +28 -0
  84. hoja_server/email_adapter.py +94 -0
  85. hoja_server/google_login.py +217 -0
  86. hoja_server/google_oidc.py +342 -0
  87. hoja_server/identity_transfer.py +210 -0
  88. hoja_server/mcp_oauth.py +623 -0
  89. hoja_server/mcp_tools.py +579 -0
  90. hoja_server/meeting_policy.py +179 -0
  91. hoja_server/meetings.py +1332 -0
  92. hoja_server/meetings_api.py +909 -0
  93. hoja_server/model_providers.py +205 -0
  94. hoja_server/realtime.py +690 -0
  95. hoja_server/resolvers.py +1281 -0
  96. hoja_server/schema.graphql +477 -0
  97. hoja_server/schema_sdl.py +8 -0
  98. hoja_server/secrets_box.py +39 -0
  99. hoja_server/signup_config.py +292 -0
  100. hoja_server/signup_gate.py +393 -0
  101. hoja_server/signup_policy.py +97 -0
  102. hoja_server/signup_store.py +577 -0
  103. hoja_server/store.py +2179 -0
  104. hoja_server/sync.py +249 -0
  105. hoja_server/tenant_context.py +131 -0
  106. hoja_server/turnstile.py +81 -0
  107. hoja_server/writer.py +1262 -0
@@ -0,0 +1,138 @@
1
+ """Reusable GraphQL fragments.
2
+
3
+ These keep our queries DRY and our model deserialization predictable —
4
+ if a model expects a field, the fragment selects it.
5
+ """
6
+
7
+ USER_CORE = """
8
+ fragment UserCore on User {
9
+ id
10
+ name
11
+ displayName
12
+ email
13
+ active
14
+ isMe
15
+ avatarUrl
16
+ }
17
+ """
18
+
19
+ TEAM_CORE = """
20
+ fragment TeamCore on Team {
21
+ id
22
+ name
23
+ key
24
+ description
25
+ color
26
+ icon
27
+ visibility
28
+ parentTeamId
29
+ timezone
30
+ }
31
+ """
32
+
33
+ STATE_CORE = """
34
+ fragment StateCore on WorkflowState {
35
+ id
36
+ name
37
+ color
38
+ description
39
+ position
40
+ type
41
+ }
42
+ """
43
+
44
+ LABEL_CORE = """
45
+ fragment LabelCore on IssueLabel {
46
+ id
47
+ name
48
+ color
49
+ description
50
+ }
51
+ """
52
+
53
+ PROJECT_CORE = """
54
+ fragment ProjectCore on Project {
55
+ id
56
+ name
57
+ description
58
+ slugId
59
+ icon
60
+ color
61
+ state
62
+ progress
63
+ startDate
64
+ targetDate
65
+ url
66
+ }
67
+ """
68
+
69
+ CYCLE_CORE = """
70
+ fragment CycleCore on Cycle {
71
+ id
72
+ name
73
+ number
74
+ startsAt
75
+ endsAt
76
+ completedAt
77
+ progress
78
+ }
79
+ """
80
+
81
+ ISSUE_CORE = """
82
+ fragment IssueCore on Issue {
83
+ id
84
+ identifier
85
+ title
86
+ priority
87
+ priorityLabel
88
+ estimate
89
+ url
90
+ branchName
91
+ number
92
+ createdAt
93
+ updatedAt
94
+ dueDate
95
+ completedAt
96
+ canceledAt
97
+ startedAt
98
+ state { ...StateCore }
99
+ assignee { ...UserCore }
100
+ creator { ...UserCore }
101
+ team { id key name }
102
+ }
103
+ """
104
+
105
+ ISSUE_FULL = """
106
+ fragment IssueFull on Issue {
107
+ ...IssueCore
108
+ description
109
+ project { ...ProjectCore }
110
+ cycle { ...CycleCore }
111
+ parent { id identifier title }
112
+ labels { nodes { ...LabelCore } }
113
+ subscribers { nodes { ...UserCore } }
114
+ }
115
+ """
116
+
117
+ PAGE_INFO = """
118
+ fragment PageInfoCore on PageInfo {
119
+ hasNextPage
120
+ hasPreviousPage
121
+ startCursor
122
+ endCursor
123
+ }
124
+ """
125
+
126
+
127
+ def assemble(*fragments: str) -> str:
128
+ """Join a set of fragments deduplicating by name."""
129
+ seen: set[str] = set()
130
+ parts: list[str] = []
131
+ for frag in fragments:
132
+ # crude fragment name extraction
133
+ name = frag.split("fragment ", 1)[1].split(" ", 1)[0]
134
+ if name in seen:
135
+ continue
136
+ seen.add(name)
137
+ parts.append(frag.strip())
138
+ return "\n\n".join(parts)
@@ -0,0 +1,160 @@
1
+ """GraphQL mutation strings.
2
+
3
+ Mutations that modify state. All return `success` and the affected entity
4
+ so we can echo the result back to the user.
5
+ """
6
+
7
+ from hoja.graphql.fragments import (
8
+ CYCLE_CORE,
9
+ ISSUE_CORE,
10
+ ISSUE_FULL,
11
+ LABEL_CORE,
12
+ PROJECT_CORE,
13
+ STATE_CORE,
14
+ USER_CORE,
15
+ assemble,
16
+ )
17
+
18
+ # ---------------------------------------------------------------------------
19
+ # Issues
20
+ # ---------------------------------------------------------------------------
21
+
22
+ ISSUE_CREATE = (
23
+ """
24
+ mutation IssueCreate($input: IssueCreateInput!) {
25
+ issueCreate(input: $input) {
26
+ success
27
+ issue { ...IssueFull }
28
+ }
29
+ }
30
+ """
31
+ + assemble(ISSUE_FULL, ISSUE_CORE, USER_CORE, STATE_CORE, PROJECT_CORE, CYCLE_CORE, LABEL_CORE)
32
+ )
33
+
34
+ ISSUE_UPDATE = (
35
+ """
36
+ mutation IssueUpdate($id: String!, $input: IssueUpdateInput!) {
37
+ issueUpdate(id: $id, input: $input) {
38
+ success
39
+ issue { ...IssueCore }
40
+ }
41
+ }
42
+ """
43
+ + assemble(ISSUE_CORE, USER_CORE, STATE_CORE)
44
+ )
45
+
46
+ ISSUE_DELETE = """
47
+ mutation IssueDelete($id: String!) {
48
+ issueDelete(id: $id) {
49
+ success
50
+ }
51
+ }
52
+ """
53
+
54
+ ISSUE_ARCHIVE = """
55
+ mutation IssueArchive($id: String!) {
56
+ issueArchive(id: $id) {
57
+ success
58
+ }
59
+ }
60
+ """
61
+
62
+ # ---------------------------------------------------------------------------
63
+ # Comments
64
+ # ---------------------------------------------------------------------------
65
+
66
+ COMMENT_CREATE = """
67
+ mutation CommentCreate($input: CommentCreateInput!) {
68
+ commentCreate(input: $input) {
69
+ success
70
+ comment {
71
+ id
72
+ body
73
+ url
74
+ createdAt
75
+ }
76
+ }
77
+ }
78
+ """
79
+
80
+ COMMENT_UPDATE = """
81
+ mutation CommentUpdate($id: String!, $input: CommentUpdateInput!) {
82
+ commentUpdate(id: $id, input: $input) {
83
+ success
84
+ comment {
85
+ id
86
+ body
87
+ url
88
+ updatedAt
89
+ }
90
+ }
91
+ }
92
+ """
93
+
94
+ COMMENT_DELETE = """
95
+ mutation CommentDelete($id: String!) {
96
+ commentDelete(id: $id) {
97
+ success
98
+ }
99
+ }
100
+ """
101
+
102
+ # ---------------------------------------------------------------------------
103
+ # Labels
104
+ # ---------------------------------------------------------------------------
105
+
106
+ LABEL_CREATE = (
107
+ """
108
+ mutation LabelCreate($input: IssueLabelCreateInput!) {
109
+ issueLabelCreate(input: $input) {
110
+ success
111
+ issueLabel { ...LabelCore }
112
+ }
113
+ }
114
+ """
115
+ + assemble(LABEL_CORE)
116
+ )
117
+
118
+ LABEL_DELETE = """
119
+ mutation LabelDelete($id: String!) {
120
+ issueLabelDelete(id: $id) {
121
+ success
122
+ }
123
+ }
124
+ """
125
+
126
+ # ---------------------------------------------------------------------------
127
+ # Projects
128
+ # ---------------------------------------------------------------------------
129
+
130
+ PROJECT_CREATE = (
131
+ """
132
+ mutation ProjectCreate($input: ProjectCreateInput!) {
133
+ projectCreate(input: $input) {
134
+ success
135
+ project { ...ProjectCore }
136
+ }
137
+ }
138
+ """
139
+ + assemble(PROJECT_CORE)
140
+ )
141
+
142
+ PROJECT_UPDATE = (
143
+ """
144
+ mutation ProjectUpdate($id: String!, $input: ProjectUpdateInput!) {
145
+ projectUpdate(id: $id, input: $input) {
146
+ success
147
+ project { ...ProjectCore }
148
+ }
149
+ }
150
+ """
151
+ + assemble(PROJECT_CORE)
152
+ )
153
+
154
+ PROJECT_ARCHIVE = """
155
+ mutation ProjectArchive($id: String!) {
156
+ projectArchive(id: $id) {
157
+ success
158
+ }
159
+ }
160
+ """
@@ -0,0 +1,245 @@
1
+ """GraphQL query strings.
2
+
3
+ These return read-only data. Each query template includes the fragments
4
+ it needs at the bottom.
5
+ """
6
+
7
+ from hoja.graphql.fragments import (
8
+ CYCLE_CORE,
9
+ ISSUE_CORE,
10
+ ISSUE_FULL,
11
+ LABEL_CORE,
12
+ PAGE_INFO,
13
+ PROJECT_CORE,
14
+ STATE_CORE,
15
+ TEAM_CORE,
16
+ USER_CORE,
17
+ assemble,
18
+ )
19
+
20
+ # ---------------------------------------------------------------------------
21
+ # Viewer / Me
22
+ # ---------------------------------------------------------------------------
23
+
24
+ VIEWER = (
25
+ """
26
+ query Viewer {
27
+ viewer {
28
+ ...UserCore
29
+ admin
30
+ url
31
+ timezone
32
+ statusEmoji
33
+ statusLabel
34
+ }
35
+ }
36
+ """
37
+ + assemble(USER_CORE)
38
+ )
39
+
40
+ # ---------------------------------------------------------------------------
41
+ # Teams
42
+ # ---------------------------------------------------------------------------
43
+
44
+ TEAMS = (
45
+ """
46
+ query Teams($first: Int = 50, $after: String) {
47
+ teams(first: $first, after: $after) {
48
+ nodes { ...TeamCore }
49
+ pageInfo { ...PageInfoCore }
50
+ }
51
+ }
52
+ """
53
+ + assemble(TEAM_CORE, PAGE_INFO)
54
+ )
55
+
56
+ TEAM_BY_KEY = (
57
+ """
58
+ query TeamByKey($key: String!) {
59
+ teams(filter: { key: { eq: $key } }, first: 1) {
60
+ nodes { ...TeamCore }
61
+ }
62
+ }
63
+ """
64
+ + assemble(TEAM_CORE)
65
+ )
66
+
67
+ TEAM_STATES = (
68
+ """
69
+ query TeamStates($teamId: String!) {
70
+ team(id: $teamId) {
71
+ id
72
+ key
73
+ name
74
+ states {
75
+ nodes { ...StateCore }
76
+ }
77
+ }
78
+ }
79
+ """
80
+ + assemble(STATE_CORE)
81
+ )
82
+
83
+ TEAM_MEMBERS = (
84
+ """
85
+ query TeamMembers($teamId: String!, $first: Int = 100) {
86
+ team(id: $teamId) {
87
+ id
88
+ key
89
+ name
90
+ members(first: $first) {
91
+ nodes { ...UserCore }
92
+ }
93
+ }
94
+ }
95
+ """
96
+ + assemble(USER_CORE)
97
+ )
98
+
99
+ # ---------------------------------------------------------------------------
100
+ # Issues
101
+ # ---------------------------------------------------------------------------
102
+
103
+ ISSUES_LIST = (
104
+ """
105
+ query Issues($filter: IssueFilter, $first: Int = 50, $after: String, $orderBy: PaginationOrderBy) {
106
+ issues(filter: $filter, first: $first, after: $after, orderBy: $orderBy) {
107
+ nodes { ...IssueCore }
108
+ pageInfo { ...PageInfoCore }
109
+ }
110
+ }
111
+ """
112
+ + assemble(ISSUE_CORE, USER_CORE, STATE_CORE, PAGE_INFO)
113
+ )
114
+
115
+ ISSUE_BY_IDENTIFIER = (
116
+ """
117
+ query IssueByIdentifier($id: String!) {
118
+ issue(id: $id) {
119
+ ...IssueFull
120
+ }
121
+ }
122
+ """
123
+ + assemble(ISSUE_FULL, ISSUE_CORE, USER_CORE, STATE_CORE, PROJECT_CORE, CYCLE_CORE, LABEL_CORE)
124
+ )
125
+
126
+ # ---------------------------------------------------------------------------
127
+ # Projects
128
+ # ---------------------------------------------------------------------------
129
+
130
+ PROJECTS_LIST = (
131
+ """
132
+ query Projects($filter: ProjectFilter, $first: Int = 50, $after: String) {
133
+ projects(filter: $filter, first: $first, after: $after) {
134
+ nodes { ...ProjectCore }
135
+ pageInfo { ...PageInfoCore }
136
+ }
137
+ }
138
+ """
139
+ + assemble(PROJECT_CORE, PAGE_INFO)
140
+ )
141
+
142
+ PROJECT_BY_ID = (
143
+ """
144
+ query Project($id: String!) {
145
+ project(id: $id) {
146
+ ...ProjectCore
147
+ lead { ...UserCore }
148
+ creator { ...UserCore }
149
+ members { nodes { ...UserCore } }
150
+ }
151
+ }
152
+ """
153
+ + assemble(PROJECT_CORE, USER_CORE)
154
+ )
155
+
156
+ # ---------------------------------------------------------------------------
157
+ # Cycles
158
+ # ---------------------------------------------------------------------------
159
+
160
+ TEAM_CYCLES = (
161
+ """
162
+ query TeamCycles($teamId: String!, $first: Int = 20) {
163
+ team(id: $teamId) {
164
+ id
165
+ key
166
+ cycles(first: $first) {
167
+ nodes { ...CycleCore }
168
+ }
169
+ activeCycle { ...CycleCore }
170
+ }
171
+ }
172
+ """
173
+ + assemble(CYCLE_CORE)
174
+ )
175
+
176
+ # ---------------------------------------------------------------------------
177
+ # Comments
178
+ # ---------------------------------------------------------------------------
179
+
180
+ ISSUE_COMMENTS = (
181
+ """
182
+ query IssueComments($issueId: String!, $first: Int = 50) {
183
+ issue(id: $issueId) {
184
+ id
185
+ identifier
186
+ comments(first: $first) {
187
+ nodes {
188
+ id
189
+ body
190
+ url
191
+ createdAt
192
+ updatedAt
193
+ user { ...UserCore }
194
+ }
195
+ }
196
+ }
197
+ }
198
+ """
199
+ + assemble(USER_CORE)
200
+ )
201
+
202
+ # ---------------------------------------------------------------------------
203
+ # Workflow / Labels
204
+ # ---------------------------------------------------------------------------
205
+
206
+ ISSUE_LABELS = (
207
+ """
208
+ query IssueLabels($filter: IssueLabelFilter, $first: Int = 100) {
209
+ issueLabels(filter: $filter, first: $first) {
210
+ nodes { ...LabelCore }
211
+ }
212
+ }
213
+ """
214
+ + assemble(LABEL_CORE)
215
+ )
216
+
217
+ # ---------------------------------------------------------------------------
218
+ # Search
219
+ # ---------------------------------------------------------------------------
220
+
221
+ # searchIssues returns IssueSearchResult which extends Issue with metadata.
222
+ # We use inline fragment to spread Issue fields onto IssueSearchResult.
223
+ SEARCH_ISSUES = (
224
+ """
225
+ query SearchIssues($term: String!, $first: Int = 20) {
226
+ searchIssues(term: $term, first: $first) {
227
+ nodes {
228
+ id
229
+ identifier
230
+ title
231
+ priority
232
+ priorityLabel
233
+ url
234
+ number
235
+ createdAt
236
+ updatedAt
237
+ state { ...StateCore }
238
+ assignee { ...UserCore }
239
+ team { id key name }
240
+ }
241
+ }
242
+ }
243
+ """
244
+ + assemble(USER_CORE, STATE_CORE)
245
+ )
hoja/mcp/__init__.py ADDED
@@ -0,0 +1,27 @@
1
+ """MCP server for Hoja.
2
+
3
+ This package is **optional**. Install with:
4
+
5
+ pip install 'hoja[mcp]'
6
+
7
+ The server exposes:
8
+ - 1 tool: hoja_guide(topic)
9
+ - 7 resources: read-only workspace context
10
+ - 6 prompts: workflow templates (triage, daily-standup, hand-off, ...)
11
+
12
+ It does NOT expose mutation tools by design. Mutations are performed via the
13
+ `hoja` CLI in a shell — that's the supported interface and the agent is
14
+ reinforced to use Bash for any write operation.
15
+
16
+ Entry point: `hoja-mcp` (console script). See `server.main()`.
17
+ """
18
+
19
+
20
+ def main() -> None:
21
+ """Run the MCP server without importing it during package initialization."""
22
+ from .server import main as server_main
23
+
24
+ server_main()
25
+
26
+
27
+ __all__ = ["main"]
hoja/mcp/content.py ADDED
@@ -0,0 +1,151 @@
1
+ """Skill content loader + Pydantic model for the `hoja_guide` MCP tool.
2
+
3
+ Loads the canonical markdown files from `hoja/skill_content/*.md` (shipped
4
+ inside the wheel via `[tool.hatch.build.targets.wheel.force-include]`).
5
+ """
6
+ from __future__ import annotations
7
+
8
+ from enum import Enum
9
+ from importlib.resources import files
10
+ from pathlib import Path
11
+
12
+ from pydantic import BaseModel, Field
13
+
14
+
15
+ # ---------------------------------------------------------------------------
16
+ # Topic enum — kebab-case in protocol, snake_case in Python
17
+ # ---------------------------------------------------------------------------
18
+ class Topic(str, Enum):
19
+ """Skill topics. String values match the markdown filenames (no extension)."""
20
+
21
+ OVERVIEW = "overview"
22
+ COMMANDS = "commands"
23
+ WORKFLOWS = "workflows"
24
+ FILTERS = "filters"
25
+ OUTPUT_FORMATS = "output-formats"
26
+ EXAMPLES = "examples"
27
+
28
+
29
+ # ---------------------------------------------------------------------------
30
+ # Pydantic response model for the MCP tool
31
+ # ---------------------------------------------------------------------------
32
+ DEFAULT_REMINDER = (
33
+ "Run hoja commands via the Bash tool. Do NOT write Python wrappers "
34
+ "around the Linear GraphQL API — Hoja already handles auth, retries, "
35
+ "pagination, validation, and error mapping. Resources on this MCP server "
36
+ "give you read-only context for free; perform every mutation via "
37
+ "`hoja <command>` in a shell."
38
+ )
39
+
40
+
41
+ class HojaGuide(BaseModel):
42
+ """Structured response for the `hoja_guide` tool.
43
+
44
+ The agent that calls this tool sees:
45
+ - `instructions`: full markdown content for the requested topic
46
+ - `key_commands`: a short hot-list of one-liners
47
+ - `see_also`: other topics worth reading
48
+ - `reminder`: the behavioral reinforcement string (shown on every reply)
49
+ """
50
+
51
+ topic: Topic = Field(description="Which topic this guide covers.")
52
+ title: str = Field(description="Human-readable title for the topic.")
53
+ instructions: str = Field(
54
+ description="Markdown instructions teaching the agent how to use Hoja for this topic.",
55
+ )
56
+ key_commands: list[str] = Field(
57
+ default_factory=list,
58
+ description="One-liner shell commands the agent should know.",
59
+ )
60
+ see_also: list[Topic] = Field(
61
+ default_factory=list,
62
+ description="Other topics linked from this one.",
63
+ )
64
+ reminder: str = Field(
65
+ default=DEFAULT_REMINDER,
66
+ description="Behavioral reminder. Read it on every response.",
67
+ )
68
+
69
+
70
+ # Deprecated public model alias for one compatibility release.
71
+
72
+
73
+ # ---------------------------------------------------------------------------
74
+ # Content loading
75
+ # ---------------------------------------------------------------------------
76
+ def _content_dir() -> Path:
77
+ """Locate the bundled skill_content directory inside the installed package."""
78
+ return Path(str(files("hoja").joinpath("skill_content")))
79
+
80
+
81
+ def _read_topic_file(topic: Topic) -> str:
82
+ """Read the markdown file for a topic. Raises FileNotFoundError if missing."""
83
+ path = _content_dir() / f"{topic.value}.md"
84
+ return path.read_text(encoding="utf-8")
85
+
86
+
87
+ def _extract_title(markdown: str, fallback: str) -> str:
88
+ """Pull the first `# heading` from the markdown body."""
89
+ for line in markdown.splitlines():
90
+ s = line.strip()
91
+ if s.startswith("# "):
92
+ return s[2:].strip()
93
+ return fallback
94
+
95
+
96
+ def _extract_key_commands(markdown: str, limit: int = 10) -> list[str]:
97
+ """Grab the first N `hoja ...` one-liners that look like bash commands.
98
+
99
+ Heuristic: scan fenced ```bash blocks, collect lines starting with `hoja `.
100
+ Skip comment lines and empty lines. Deduplicate while preserving order.
101
+ """
102
+ out: list[str] = []
103
+ seen: set[str] = set()
104
+ in_block = False
105
+ is_bash = False
106
+ for raw in markdown.splitlines():
107
+ line = raw.rstrip()
108
+ if line.startswith("```"):
109
+ if in_block:
110
+ in_block = False
111
+ is_bash = False
112
+ else:
113
+ in_block = True
114
+ # Detect language tag (```bash, ```sh, ```shell) or untagged blocks
115
+ tag = line.removeprefix("```").strip().lower()
116
+ is_bash = tag in {"", "bash", "sh", "shell"}
117
+ continue
118
+ if not in_block or not is_bash:
119
+ continue
120
+ stripped = line.strip()
121
+ if not stripped or stripped.startswith("#"):
122
+ continue
123
+ if stripped.startswith("hoja ") and stripped not in seen:
124
+ seen.add(stripped)
125
+ out.append(stripped)
126
+ if len(out) >= limit:
127
+ return out
128
+ return out
129
+
130
+
131
+ def _see_also_for(topic: Topic) -> list[Topic]:
132
+ """Static cross-link map. Mirrors the 'See also' sections in the markdown."""
133
+ base = [t for t in Topic if t is not topic]
134
+ return base
135
+
136
+
137
+ def load_topic(topic: Topic) -> HojaGuide:
138
+ """Load a topic's full guide.
139
+
140
+ Returns a Pydantic-validated `HojaGuide` ready to be returned from the
141
+ MCP tool. The MCP SDK will auto-serialize to JSON for the wire.
142
+ """
143
+ body = _read_topic_file(topic)
144
+ title = _extract_title(body, fallback=f"Hoja — {topic.value}")
145
+ return HojaGuide(
146
+ topic=topic,
147
+ title=title,
148
+ instructions=body,
149
+ key_commands=_extract_key_commands(body),
150
+ see_also=_see_also_for(topic),
151
+ )