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
hoja/mcp/prompts.py ADDED
@@ -0,0 +1,298 @@
1
+ """MCP prompt templates.
2
+
3
+ Each prompt returns a multi-line markdown string. The MCP client renders this
4
+ as a user message that walks the agent through running `hoja` commands in
5
+ a specific order. Every prompt:
6
+
7
+ 1. Lists the exact shell commands to execute (in order).
8
+ 2. Says what to look for in each response.
9
+ 3. Includes the standard behavioral reminder at the end.
10
+
11
+ The prompts NEVER perform mutations themselves. They tell the agent which
12
+ `hoja` commands to call via the Bash tool.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ REMINDER = (
17
+ "REMINDER: run each command above via the Bash tool. Do not write Python "
18
+ "wrappers around Linear's GraphQL API — `hoja` is the supported "
19
+ "interface. Use `--output json` when piping or programmatically reading."
20
+ )
21
+
22
+
23
+ def triage(team_key: str) -> str:
24
+ """Walk the agent through triaging unassigned issues for a team."""
25
+ return f"""# Triage workflow for team `{team_key}`
26
+
27
+ You are about to triage the unassigned backlog for team **{team_key}**.
28
+
29
+ ## Step 1 — Scope the queue
30
+
31
+ Run:
32
+
33
+ ```bash
34
+ hoja issue list --team {team_key} --no-assignee --state Todo -n 20
35
+ ```
36
+
37
+ Read the list. Identify the top 3–5 issues that look highest-priority based on
38
+ title + label.
39
+
40
+ ## Step 2 — Inspect each candidate
41
+
42
+ For each candidate ID:
43
+
44
+ ```bash
45
+ hoja issue get <ID>
46
+ ```
47
+
48
+ Skim the description and recent comments. Decide:
49
+
50
+ - Does it have enough context? If not, comment asking the reporter.
51
+ - Is the priority field obviously wrong? Note the correction.
52
+ - Is the right label set?
53
+
54
+ ## Step 3 — Act
55
+
56
+ For each issue you've decided to take action on:
57
+
58
+ ```bash
59
+ hoja issue assign <ID> <USER_NAME> # or 'me'
60
+ hoja issue update <ID> --priority <N> --label "<labels>"
61
+ hoja issue state <ID> "<NEXT_STATE>" # e.g. "In Progress"
62
+ ```
63
+
64
+ ## Step 4 — Document
65
+
66
+ If the triage decision needs a paper trail (e.g. reassigning across teams),
67
+ post a comment:
68
+
69
+ ```bash
70
+ hoja comment add <ID> --body "Triaged: assigned to <name>, priority <N> because <reason>"
71
+ ```
72
+
73
+ {REMINDER}
74
+ """
75
+
76
+
77
+ def daily_standup() -> str:
78
+ """Render a standup-shaped report of viewer's current work."""
79
+ return f"""# Daily standup workflow
80
+
81
+ ## Step 1 — What I shipped yesterday
82
+
83
+ ```bash
84
+ hoja -o md issue list --assignee me --state Done \\
85
+ --updated-after $(date -d "1 day ago" +%Y-%m-%d) -n 20
86
+ ```
87
+
88
+ ## Step 2 — What I'm working on today
89
+
90
+ ```bash
91
+ hoja -o md issue list --assignee me --state "In Progress" -n 20
92
+ hoja -o md issue list --assignee me --state "In Review" -n 20
93
+ ```
94
+
95
+ ## Step 3 — Blockers
96
+
97
+ ```bash
98
+ hoja -o md issue list --assignee me --label blocked -n 10
99
+ ```
100
+
101
+ ## Step 4 — Compose the standup
102
+
103
+ Combine the three sections above into:
104
+
105
+ ```
106
+ ## Yesterday
107
+ - <ENG-XX>: title
108
+ - ...
109
+
110
+ ## Today
111
+ - <ENG-YY>: title
112
+ - ...
113
+
114
+ ## Blockers
115
+ - <ENG-ZZ>: title — reason
116
+ ```
117
+
118
+ {REMINDER}
119
+ """
120
+
121
+
122
+ def create_from_error(error_text: str, team_key: str, priority: int = 3) -> str:
123
+ """Templated issue creation from an error message / stack trace."""
124
+ # Extract a plausible title — first line of the error, capped at ~80 chars
125
+ first_line = (error_text or "").strip().splitlines()[:1]
126
+ title_hint = (first_line[0][:80] if first_line else "Unhandled error").strip()
127
+ return f"""# Create issue from error workflow
128
+
129
+ The user has provided an error trace and asked you to file an issue.
130
+
131
+ ## Step 1 — Search for duplicates FIRST
132
+
133
+ ```bash
134
+ hoja issue search "{title_hint}" -n 5
135
+ ```
136
+
137
+ If a matching issue exists, do not create a new one — comment on the existing
138
+ issue instead:
139
+
140
+ ```bash
141
+ hoja comment add <EXISTING_ID> --body "<<<DELIM
142
+ {error_text}
143
+ DELIM"
144
+ ```
145
+
146
+ ## Step 2 — If no duplicate, create
147
+
148
+ ```bash
149
+ hoja issue create \\
150
+ --team {team_key} \\
151
+ --title "{title_hint}" \\
152
+ --description "<<<DELIM
153
+ {error_text}
154
+ DELIM" \\
155
+ --priority {priority} \\
156
+ --label "bug"
157
+ ```
158
+
159
+ ## Step 3 — Capture the new identifier
160
+
161
+ The create response includes the new issue's identifier (e.g. `{team_key}-NN`).
162
+ Surface it to the user along with `hoja issue url <ID>` for a clickable link.
163
+
164
+ {REMINDER}
165
+ """
166
+
167
+
168
+ def hand_off(issue_id: str, to_user: str, note: str = "") -> str:
169
+ """Three-command issue hand-off pattern."""
170
+ note_line = note or "context: see related PR / discussion / commit"
171
+ return f"""# Hand-off workflow for {issue_id} → {to_user}
172
+
173
+ A clean hand-off is three commands. Run them in order:
174
+
175
+ ## Step 1 — Reassign
176
+
177
+ ```bash
178
+ hoja issue assign {issue_id} "{to_user}"
179
+ ```
180
+
181
+ ## Step 2 — Move to a review/blocked state
182
+
183
+ ```bash
184
+ hoja issue state {issue_id} "In Review"
185
+ ```
186
+
187
+ (Substitute the right state name. Use `hoja team states <TEAM_KEY>` if you
188
+ need to confirm available states for this team.)
189
+
190
+ ## Step 3 — Document the hand-off
191
+
192
+ ```bash
193
+ hoja comment add {issue_id} --body "Handing off to {to_user} — {note_line}"
194
+ ```
195
+
196
+ **Behavioral rule:** never reassign without a comment explaining why and what
197
+ the receiver needs to know. A bare reassignment creates ambiguity downstream.
198
+
199
+ {REMINDER}
200
+ """
201
+
202
+
203
+ def cycle_review(team_key: str) -> str:
204
+ """Render a current-cycle review for a team."""
205
+ return f"""# Cycle review workflow for team `{team_key}`
206
+
207
+ ## Step 1 — Confirm there is an active cycle
208
+
209
+ ```bash
210
+ hoja cycle current {team_key}
211
+ ```
212
+
213
+ If the response indicates no active cycle, stop and tell the user — there is
214
+ nothing to review.
215
+
216
+ ## Step 2 — Inventory the cycle by state
217
+
218
+ ```bash
219
+ hoja -o json issue list --cycle current --team {team_key} \\
220
+ | jq 'group_by(.state.name) | map({{state: .[0].state.name, count: length, issues: map(.identifier)}})'
221
+ ```
222
+
223
+ ## Step 3 — Flag blockers
224
+
225
+ ```bash
226
+ hoja issue list --cycle current --team {team_key} --label blocked
227
+ ```
228
+
229
+ ## Step 4 — Flag stale issues (no update in 5+ days)
230
+
231
+ ```bash
232
+ hoja issue list --cycle current --team {team_key} \\
233
+ --updated-before $(date -d "5 days ago" +%Y-%m-%d)
234
+ ```
235
+
236
+ ## Step 5 — Report
237
+
238
+ Combine the above into a structured summary:
239
+
240
+ - **Done**: N issues
241
+ - **In Progress**: N issues
242
+ - **Todo (not started)**: N issues — risk if cycle ends soon
243
+ - **Blockers**: list with reasons
244
+ - **Stale**: list with last-update dates
245
+
246
+ {REMINDER}
247
+ """
248
+
249
+
250
+ def issue_investigate(issue_id: str) -> str:
251
+ """Deep dive on a single issue."""
252
+ return f"""# Investigate {issue_id} — full context dump
253
+
254
+ ## Step 1 — Read the issue
255
+
256
+ ```bash
257
+ hoja -o json issue get {issue_id}
258
+ ```
259
+
260
+ Capture: title, description, state, priority, assignee, labels, project, cycle.
261
+
262
+ ## Step 2 — Read every comment
263
+
264
+ ```bash
265
+ hoja comment list {issue_id} -n 100
266
+ ```
267
+
268
+ ## Step 3 — Find related issues (same labels)
269
+
270
+ ```bash
271
+ LABEL=$(hoja -o json issue get {issue_id} | jq -r '.labels[0].name // empty')
272
+ [ -n "$LABEL" ] && hoja issue list --label "$LABEL" -n 10
273
+ ```
274
+
275
+ ## Step 4 — Find related issues (same project / cycle)
276
+
277
+ ```bash
278
+ PROJECT=$(hoja -o json issue get {issue_id} | jq -r '.project.id // empty')
279
+ [ -n "$PROJECT" ] && hoja issue list --project "$PROJECT" -n 10
280
+
281
+ CYCLE=$(hoja -o json issue get {issue_id} | jq -r '.cycle.id // empty')
282
+ [ -n "$CYCLE" ] && hoja -o json issue list --cycle "$CYCLE" | jq '[.[] | .identifier]'
283
+ ```
284
+
285
+ ## Step 5 — Suggest next action
286
+
287
+ Based on state + labels + comments, suggest one of:
288
+ - comment with a clarifying question
289
+ - assign (with `hoja issue assign`)
290
+ - move state (with `hoja issue state`)
291
+ - close as duplicate (set state to `Canceled` and link via comment)
292
+ - prioritize (with `hoja issue prio`)
293
+
294
+ Do **not** mutate the issue yet — propose the action and wait for the user's
295
+ go-ahead.
296
+
297
+ {REMINDER}
298
+ """
hoja/mcp/resources.py ADDED
@@ -0,0 +1,213 @@
1
+ """Read-only Linear resources exposed via MCP.
2
+
3
+ Each resource handler:
4
+ 1. Resolves auth (LinearClient via env / config).
5
+ 2. Issues a GraphQL query.
6
+ 3. Validates response into a Pydantic model.
7
+ 4. Returns JSON text (string).
8
+
9
+ If auth is missing, the handler raises `RuntimeError` with a friendly message;
10
+ the FastMCP runtime maps that to a JSON-RPC error so the server keeps running.
11
+
12
+ All resources are **read-only**. Mutations live in the `hoja` CLI — the
13
+ agent is expected to call those via the Bash tool. The `reminder` field on
14
+ the tool response and the prompt bodies reinforce this rule.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ from typing import Any
20
+
21
+ from hoja.client import LinearClient
22
+ from hoja.config import load_config, resolve_account, resolve_token
23
+ from hoja.errors import AuthError, HojaError
24
+ from hoja.graphql import queries
25
+ from hoja.models.cycle import Cycle
26
+ from hoja.models.issue import Issue
27
+ from hoja.models.project import Project
28
+ from hoja.models.team import Team
29
+ from hoja.models.user import User
30
+ from hoja.models.workflow import WorkflowState
31
+
32
+
33
+ # ---------------------------------------------------------------------------
34
+ # Auth helper
35
+ # ---------------------------------------------------------------------------
36
+ def _client() -> LinearClient:
37
+ """Build a LinearClient using the same resolution rules as the CLI.
38
+
39
+ Order: $HOJA_TOKEN env var (deprecated alias $LINEAR_TOKEN) > config.toml.
40
+ The `--token` CLI flag is not applicable here (the MCP server has no CLI
41
+ args for the token).
42
+ """
43
+ cfg = load_config()
44
+ try:
45
+ _account_name, account_cfg = resolve_account(None, cfg)
46
+ token = resolve_token(None, account_cfg)
47
+ except AuthError as e:
48
+ raise RuntimeError(
49
+ "hoja-mcp could not resolve an API token. "
50
+ "Set $HOJA_TOKEN, or run `hoja init` and put your token in "
51
+ "~/.config/hoja/config.toml. "
52
+ f"Underlying error: {e}"
53
+ ) from e
54
+ return LinearClient(token)
55
+
56
+
57
+ def _to_json(model: Any) -> str:
58
+ """Serialize a Pydantic model (or list of them) to indented JSON."""
59
+ if isinstance(model, list):
60
+ return json.dumps(
61
+ [m.model_dump(mode="json", exclude_none=True) for m in model],
62
+ indent=2,
63
+ ensure_ascii=False,
64
+ )
65
+ if hasattr(model, "model_dump"):
66
+ return model.model_dump_json(indent=2, exclude_none=True)
67
+ return json.dumps(model, indent=2, ensure_ascii=False, default=str)
68
+
69
+
70
+ # ---------------------------------------------------------------------------
71
+ # Resource handlers
72
+ # ---------------------------------------------------------------------------
73
+ async def viewer() -> str:
74
+ """hoja://me — currently-authenticated user."""
75
+ async with _client() as c:
76
+ user = await c.execute_as(
77
+ User, queries.VIEWER, path=["viewer"], operation="Viewer"
78
+ )
79
+ return _to_json(user)
80
+
81
+
82
+ async def issue(id: str) -> str:
83
+ """hoja://issue/{id} — full Issue including comments, labels, subscribers."""
84
+ async with _client() as c:
85
+ issue_model = await c.execute_as(
86
+ Issue,
87
+ queries.ISSUE_BY_IDENTIFIER,
88
+ {"id": id},
89
+ path=["issue"],
90
+ operation="IssueByIdentifier",
91
+ )
92
+ return _to_json(issue_model)
93
+
94
+
95
+ async def team(key: str) -> str:
96
+ """hoja://team/{key} — team + states + members rolled into one payload."""
97
+ async with _client() as c:
98
+ # Resolve team id from key
99
+ team_data = await c.execute(
100
+ queries.TEAM_BY_KEY, {"key": key}, operation="TeamByKey"
101
+ )
102
+ nodes = (team_data.get("teams") or {}).get("nodes") or []
103
+ if not nodes:
104
+ raise RuntimeError(f"No team found with key {key!r}")
105
+ team_model = Team.model_validate(nodes[0])
106
+
107
+ # States
108
+ states_raw = await c.execute(
109
+ queries.TEAM_STATES, {"teamId": team_model.id}, operation="TeamStates"
110
+ )
111
+ state_nodes = (
112
+ (states_raw.get("team") or {}).get("states") or {}
113
+ ).get("nodes") or []
114
+ states = [WorkflowState.model_validate(s) for s in state_nodes]
115
+
116
+ # Members
117
+ members_raw = await c.execute(
118
+ queries.TEAM_MEMBERS, {"teamId": team_model.id}, operation="TeamMembers"
119
+ )
120
+ member_nodes = (
121
+ (members_raw.get("team") or {}).get("members") or {}
122
+ ).get("nodes") or []
123
+ members = [User.model_validate(m) for m in member_nodes]
124
+
125
+ payload = {
126
+ "team": team_model.model_dump(mode="json", exclude_none=True),
127
+ "states": [s.model_dump(mode="json", exclude_none=True) for s in states],
128
+ "members": [m.model_dump(mode="json", exclude_none=True) for m in members],
129
+ }
130
+ return json.dumps(payload, indent=2, ensure_ascii=False, default=str)
131
+
132
+
133
+ async def project(id_or_slug: str) -> str:
134
+ """hoja://project/{id_or_slug} — Project with lead + members."""
135
+ async with _client() as c:
136
+ proj = await c.execute_as(
137
+ Project,
138
+ queries.PROJECT_BY_ID,
139
+ {"id": id_or_slug},
140
+ path=["project"],
141
+ operation="Project",
142
+ )
143
+ return _to_json(proj)
144
+
145
+
146
+ async def cycle_current(team_key: str) -> str:
147
+ """hoja://cycle/current/{team_key} — active cycle or {"active_cycle": null}."""
148
+ async with _client() as c:
149
+ # Resolve team id from key
150
+ team_data = await c.execute(
151
+ queries.TEAM_BY_KEY, {"key": team_key}, operation="TeamByKey"
152
+ )
153
+ nodes = (team_data.get("teams") or {}).get("nodes") or []
154
+ if not nodes:
155
+ raise RuntimeError(f"No team found with key {team_key!r}")
156
+ team_id = nodes[0]["id"]
157
+
158
+ # Active cycle
159
+ cycle_query = """
160
+ query ActiveCycle($teamId: String!) {
161
+ team(id: $teamId) {
162
+ activeCycle { id name number startsAt endsAt completedAt progress }
163
+ }
164
+ }
165
+ """
166
+ raw = await c.execute(cycle_query, {"teamId": team_id}, operation="ActiveCycle")
167
+ active = (raw.get("team") or {}).get("activeCycle")
168
+ if not active:
169
+ return json.dumps({"active_cycle": None, "team_key": team_key}, indent=2)
170
+ cycle_model = Cycle.model_validate(active)
171
+ return _to_json(cycle_model)
172
+
173
+
174
+ async def issues_mine() -> str:
175
+ """hoja://issues/mine — viewer's open issues (Todo + In Progress + In Review)."""
176
+ async with _client() as c:
177
+ viewer_data = await c.execute(queries.VIEWER, operation="Viewer")
178
+ viewer_id = (viewer_data.get("viewer") or {}).get("id")
179
+ if not viewer_id:
180
+ raise RuntimeError("Could not resolve viewer id")
181
+
182
+ filt = {
183
+ "assignee": {"id": {"eq": viewer_id}},
184
+ "state": {"type": {"in": ["unstarted", "started"]}},
185
+ }
186
+ issues = await c.execute_list(
187
+ Issue,
188
+ queries.ISSUES_LIST,
189
+ {"filter": filt, "first": 50},
190
+ path=["issues"],
191
+ operation="Issues",
192
+ )
193
+ return _to_json(issues)
194
+
195
+
196
+ async def issues_team(team_key: str) -> str:
197
+ """hoja://issues/team/{team_key} — open issues for a team."""
198
+ async with _client() as c:
199
+ filt = {
200
+ "team": {"key": {"eq": team_key}},
201
+ "state": {"type": {"in": ["unstarted", "started"]}},
202
+ }
203
+ try:
204
+ issues = await c.execute_list(
205
+ Issue,
206
+ queries.ISSUES_LIST,
207
+ {"filter": filt, "first": 50},
208
+ path=["issues"],
209
+ operation="Issues",
210
+ )
211
+ except HojaError as e:
212
+ raise RuntimeError(f"Could not list issues for team {team_key!r}: {e}") from e
213
+ return _to_json(issues)
hoja/mcp/server.py ADDED
@@ -0,0 +1,189 @@
1
+ """hoja-mcp — Model Context Protocol server for the hoja CLI.
2
+
3
+ Run via the `hoja-mcp` console script (registered in pyproject.toml).
4
+ Uses the official `mcp` Python SDK with FastMCP convenience layer.
5
+
6
+ Exposes:
7
+ - 1 tool : hoja_guide(topic) — teaches the agent how to use hoja
8
+ - 7 resources : read-only Linear context
9
+ - 6 prompts : workflow templates
10
+
11
+ By design the server does NOT expose mutation tools. Mutations happen via the
12
+ `hoja` CLI in a shell — the tool's response payload and the prompt bodies
13
+ both carry a behavioral reminder reinforcing this rule.
14
+
15
+ STDIO TRANSPORT NOTE: When running on stdio (the default), stdout is reserved
16
+ for the JSON-RPC stream. ALL logging must go to stderr. We configure that at
17
+ the top of this module before any other import that might log.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import logging
23
+ import sys
24
+
25
+ # Route logging to stderr BEFORE importing anything that might log.
26
+ logging.basicConfig(
27
+ stream=sys.stderr,
28
+ level=logging.INFO,
29
+ format="[hoja-mcp] %(levelname)s %(message)s",
30
+ )
31
+
32
+ # Module-level imports for types used in tool/resource/prompt signatures.
33
+ # The MCP SDK uses inspect.signature(..., eval_str=True) which resolves
34
+ # stringified annotations against MODULE globals — so Topic/HojaGuide
35
+ # must be importable at module level, not just inside _build_server().
36
+ from . import prompts as _prompts # noqa: E402
37
+ from . import resources as _resources # noqa: E402
38
+ from .content import HojaGuide, Topic, load_topic # noqa: E402
39
+
40
+
41
+ def main() -> None:
42
+ """Entry point for the `hoja-mcp` console script.
43
+
44
+ Lazy-imports the MCP SDK so that `import hoja` (without the [mcp] extra)
45
+ does not fail at the top of this module. If the dep is missing, print a
46
+ friendly install hint to stderr and exit 2.
47
+ """
48
+ try:
49
+ from mcp.server.mcpserver import MCPServer # noqa: F401
50
+ except ImportError:
51
+ sys.stderr.write(
52
+ "hoja-mcp requires the [mcp] extra.\nInstall with:\n pip install 'hoja[mcp]'\n"
53
+ )
54
+ sys.exit(2)
55
+
56
+ _build_server().run()
57
+
58
+
59
+ def _build_server():
60
+ """Wire tools, resources, and prompts onto an MCP v2 server."""
61
+ from mcp.server.mcpserver import MCPServer
62
+
63
+ from hoja import __version__
64
+
65
+ mcp = MCPServer(
66
+ name="hoja",
67
+ instructions=(
68
+ "Use this server to learn how to drive Linear from a shell using "
69
+ "the `hoja` CLI. Call `hoja_guide(topic)` BEFORE attempting "
70
+ "any Linear-related task. Read resources for read-only context. "
71
+ "Read prompts for multi-step workflow templates. Perform all "
72
+ "mutations via `hoja <command>` invoked through the Bash tool."
73
+ ),
74
+ )
75
+
76
+ # ------------------------------------------------------------------
77
+ # Tool — single teaching tool
78
+ # ------------------------------------------------------------------
79
+ @mcp.tool()
80
+ def hoja_guide(topic: Topic = Topic.OVERVIEW) -> HojaGuide:
81
+ """Return a guide on how to use the `hoja` CLI for a given topic.
82
+
83
+ ALWAYS call this BEFORE any Linear-related task. Then execute hoja
84
+ commands via the Bash tool. Do NOT write Python wrappers around the
85
+ Linear GraphQL API — hoja is the supported interface.
86
+
87
+ Available topics:
88
+ - overview — when to reach for hoja; behavioral rules
89
+ - commands — full command tree with examples
90
+ - workflows — common multi-step patterns
91
+ - filters — `issue list` filter DSL
92
+ - output-formats — when to use --output json vs human vs ids
93
+ - examples — copy-pasteable recipes
94
+ """
95
+ return load_topic(topic)
96
+
97
+ # ------------------------------------------------------------------
98
+ # Resources — read-only Linear context
99
+ # ------------------------------------------------------------------
100
+ @mcp.resource("hoja://me", name="viewer", description="Currently-authenticated Linear user.")
101
+ async def res_me() -> str:
102
+ return await _resources.viewer()
103
+
104
+ @mcp.resource(
105
+ "hoja://issue/{id}",
106
+ name="issue",
107
+ description="Full Linear issue by identifier (e.g. ENG-35), including comments, labels, subscribers.",
108
+ )
109
+ async def res_issue(id: str) -> str:
110
+ return await _resources.issue(id)
111
+
112
+ @mcp.resource(
113
+ "hoja://team/{key}",
114
+ name="team",
115
+ description="Team detail + workflow states + members for a team key (e.g. ENG).",
116
+ )
117
+ async def res_team(key: str) -> str:
118
+ return await _resources.team(key)
119
+
120
+ @mcp.resource(
121
+ "hoja://project/{id_or_slug}",
122
+ name="project",
123
+ description="Linear project by id or slug, including lead, creator, members.",
124
+ )
125
+ async def res_project(id_or_slug: str) -> str:
126
+ return await _resources.project(id_or_slug)
127
+
128
+ @mcp.resource(
129
+ "hoja://cycle/current/{team_key}",
130
+ name="cycle-current",
131
+ description="Active cycle for a team. Returns {active_cycle: null} when none.",
132
+ )
133
+ async def res_cycle(team_key: str) -> str:
134
+ return await _resources.cycle_current(team_key)
135
+
136
+ @mcp.resource(
137
+ "hoja://issues/mine",
138
+ name="issues-mine",
139
+ description="Open issues assigned to the viewer (Todo + In Progress).",
140
+ )
141
+ async def res_issues_mine() -> str:
142
+ return await _resources.issues_mine()
143
+
144
+ @mcp.resource(
145
+ "hoja://issues/team/{team_key}",
146
+ name="issues-team",
147
+ description="Open issues for a team (Todo + In Progress).",
148
+ )
149
+ async def res_issues_team(team_key: str) -> str:
150
+ return await _resources.issues_team(team_key)
151
+
152
+ # ------------------------------------------------------------------
153
+ # Prompts — workflow templates
154
+ # ------------------------------------------------------------------
155
+ @mcp.prompt(description="Triage unassigned issues for a team — step-by-step shell commands.")
156
+ def triage(team_key: str) -> str:
157
+ return _prompts.triage(team_key)
158
+
159
+ @mcp.prompt(description="Generate a daily standup report from viewer's recent activity.")
160
+ def daily_standup() -> str:
161
+ return _prompts.daily_standup()
162
+
163
+ @mcp.prompt(description="Create an issue from an error trace, with duplicate-search step.")
164
+ def create_from_error(error_text: str, team_key: str, priority: int = 3) -> str:
165
+ return _prompts.create_from_error(error_text, team_key, priority)
166
+
167
+ @mcp.prompt(description="Hand off an issue to another teammate in 3 commands.")
168
+ def hand_off(issue_id: str, to_user: str, note: str = "") -> str:
169
+ return _prompts.hand_off(issue_id, to_user, note)
170
+
171
+ @mcp.prompt(
172
+ description="Review the current cycle for a team — done/in-progress/blockers/stale."
173
+ )
174
+ def cycle_review(team_key: str) -> str:
175
+ return _prompts.cycle_review(team_key)
176
+
177
+ @mcp.prompt(description="Deep-dive investigation of a single issue.")
178
+ def issue_investigate(issue_id: str) -> str:
179
+ return _prompts.issue_investigate(issue_id)
180
+
181
+ logging.info(
182
+ "hoja-mcp v%s ready (tools=1, resources=7, prompts=6)",
183
+ __version__,
184
+ )
185
+ return mcp
186
+
187
+
188
+ if __name__ == "__main__":
189
+ main()