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,240 @@
1
+ """`hoja memory` commands — project-scoped memory board for agents.
2
+
3
+ The memory board lives at `.hoja/memory.yaml` (project root). Agents read it
4
+ before starting work (`memory remind`) and contribute learnings (`memory add`).
5
+ Stale community entries auto-heal after 30 days.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json as _json
11
+ from typing import Any
12
+
13
+ import typer
14
+
15
+ from hoja.cli_state import get_state
16
+ from hoja.errors import NotFoundError
17
+ from hoja.memory_board import (
18
+ MemoryBoard,
19
+ MemoryEntry,
20
+ heal,
21
+ load_memory,
22
+ remind,
23
+ save_memory,
24
+ )
25
+ from hoja.models.enums import OutputFormat
26
+
27
+ memory_app = typer.Typer(help="Project memory board for hoja agents")
28
+
29
+
30
+ @memory_app.callback(invoke_without_command=True)
31
+ def memory(ctx: typer.Context) -> None:
32
+ """Memory board — read before work, write after learning."""
33
+ if ctx.invoked_subcommand is not None:
34
+ return
35
+ # Default action: remind
36
+ _run_remind()
37
+
38
+
39
+ @memory_app.command("remind")
40
+ def memory_remind() -> None:
41
+ """Print the memory board digest (what agents see before starting work)."""
42
+ _run_remind()
43
+
44
+
45
+ def _run_remind() -> None:
46
+ state = get_state()
47
+ board = load_memory()
48
+ digest = remind(board)
49
+ if state.output == OutputFormat.JSON:
50
+ data = {
51
+ "forced": [_entry_dict(e) for e in board.forced],
52
+ "community": [_entry_dict(e) for e in board.community if not _is_stale(e)],
53
+ }
54
+ print(_json.dumps(data, indent=2))
55
+ return
56
+ typer.echo(digest)
57
+
58
+
59
+ @memory_app.command("list")
60
+ def memory_list() -> None:
61
+ """List all memory entries with metadata."""
62
+ state = get_state()
63
+ board = load_memory()
64
+ if state.output == OutputFormat.JSON:
65
+ data = {
66
+ "forced": [_entry_dict(e) for e in board.forced],
67
+ "community": [_entry_dict(e) for e in board.community],
68
+ }
69
+ print(_json.dumps(data, indent=2))
70
+ return
71
+
72
+ typer.echo("Memory board entries:")
73
+ typer.echo("")
74
+ if board.forced:
75
+ typer.echo("Forced rules:")
76
+ for e in sorted(board.forced, key=lambda x: x.priority):
77
+ stale = " [stale]" if _is_stale(e) else ""
78
+ typer.echo(f" [{e.priority}] {e.id}{stale}")
79
+ typer.echo(f" {e.title}")
80
+ if board.community:
81
+ typer.echo("")
82
+ typer.echo("Community entries:")
83
+ for e in sorted(board.community, key=lambda x: x.priority):
84
+ stale = " [stale]" if _is_stale(e) else ""
85
+ typer.echo(f" [{e.priority}] {e.id}{stale}")
86
+ typer.echo(f" {e.title}")
87
+ typer.echo(f" (added {e.created_at[:10]} by {e.created_by})")
88
+ else:
89
+ typer.echo("No community entries yet.")
90
+
91
+
92
+ @memory_app.command("add")
93
+ def memory_add(
94
+ title: str = typer.Option(..., "--title", "-t", help="Short title for the memory"),
95
+ body: str = typer.Option(..., "--body", "-b", help="Body text — what you learned"),
96
+ priority: int = typer.Option(5, "--priority", "-p", help="Priority 1-10 (1 = most important)"),
97
+ auto_heal: bool = typer.Option(True, "--auto-heal/--no-auto-heal", help="Auto-remove after heal_after_days"),
98
+ heal_after_days: int = typer.Option(30, "--heal-after-days", help="Days before auto-removal"),
99
+ ) -> None:
100
+ """Add a community memory entry."""
101
+ board = load_memory()
102
+ entry_id = _slugify(title)
103
+ # Handle duplicates by appending timestamp
104
+ existing_ids = {e.id for e in board.forced + board.community}
105
+ if entry_id in existing_ids:
106
+ entry_id = f"{entry_id}-{__import__('time').time():.0f}"
107
+
108
+ entry = MemoryEntry(
109
+ id=entry_id,
110
+ priority=priority,
111
+ title=title,
112
+ body=body,
113
+ auto_heal=auto_heal,
114
+ heal_after_days=heal_after_days,
115
+ )
116
+ board.community.append(entry)
117
+ save_memory(board)
118
+ typer.echo(f"Added memory '{entry_id}'")
119
+
120
+
121
+ @memory_app.command("update")
122
+ def memory_update(
123
+ entry_id: str = typer.Argument(..., help="Entry ID to update"),
124
+ body: str | None = typer.Option(None, "--body", "-b", help="New body text"),
125
+ title: str | None = typer.Option(None, "--title", "-t", help="New title"),
126
+ priority: int | None = typer.Option(None, "--priority", "-p", help="New priority"),
127
+ ) -> None:
128
+ """Update an existing memory entry."""
129
+ board = load_memory()
130
+ entry = _find_entry(board, entry_id)
131
+ if entry is None:
132
+ raise NotFoundError(
133
+ f"Memory entry '{entry_id}' not found",
134
+ hint="Run 'hoja memory list' to see available IDs.",
135
+ )
136
+ if title is not None:
137
+ entry.title = title
138
+ if body is not None:
139
+ entry.body = body
140
+ if priority is not None:
141
+ entry.priority = priority
142
+ entry.last_updated = __import__("hoja.memory_board", fromlist=["_now"])._now()
143
+ save_memory(board)
144
+ typer.echo(f"Updated memory '{entry_id}'")
145
+
146
+
147
+ @memory_app.command("remove")
148
+ def memory_remove(
149
+ entry_id: str = typer.Argument(..., help="Entry ID to remove"),
150
+ ) -> None:
151
+ """Remove a memory entry."""
152
+ board = load_memory()
153
+ removed = _remove_entry(board, entry_id)
154
+ if not removed:
155
+ raise NotFoundError(
156
+ f"Memory entry '{entry_id}' not found",
157
+ hint="Run 'hoja memory list' to see available IDs.",
158
+ )
159
+ save_memory(board)
160
+ typer.echo(f"Removed memory '{entry_id}'")
161
+
162
+
163
+ @memory_app.command("heal")
164
+ def memory_heal(
165
+ dry_run: bool = typer.Option(False, "--dry-run", help="Show what would be removed without deleting"),
166
+ ) -> None:
167
+ """Remove stale community entries (older than heal_after_days)."""
168
+ board = load_memory()
169
+ removed = heal(board)
170
+ if dry_run:
171
+ if removed:
172
+ typer.echo("Would remove:")
173
+ for rid in removed:
174
+ typer.echo(f" - {rid}")
175
+ else:
176
+ typer.echo("Nothing to heal — all community entries are fresh.")
177
+ return
178
+ if removed:
179
+ save_memory(board)
180
+ typer.echo("Healed stale entries:")
181
+ for rid in removed:
182
+ typer.echo(f" - {rid}")
183
+ else:
184
+ typer.echo("Nothing to heal — all community entries are fresh.")
185
+
186
+
187
+ # ---------------------------------------------------------------------------
188
+ # Helpers
189
+ # ---------------------------------------------------------------------------
190
+
191
+ def _entry_dict(entry: MemoryEntry) -> dict[str, Any]:
192
+ return {
193
+ "id": entry.id,
194
+ "priority": entry.priority,
195
+ "title": entry.title,
196
+ "body": entry.body,
197
+ "created_by": entry.created_by,
198
+ "created_at": entry.created_at,
199
+ "last_updated": entry.last_updated,
200
+ "auto_heal": entry.auto_heal,
201
+ "heal_after_days": entry.heal_after_days,
202
+ "stale": _is_stale(entry),
203
+ }
204
+
205
+
206
+ def _is_stale(entry: MemoryEntry) -> bool:
207
+ """Check if an entry is stale (for display purposes only)."""
208
+ if not entry.auto_heal or entry.heal_after_days <= 0:
209
+ return False
210
+ try:
211
+ from datetime import datetime, timezone
212
+ created = datetime.fromisoformat(entry.created_at)
213
+ age = datetime.now(timezone.utc) - created
214
+ return age.days > entry.heal_after_days
215
+ except (ValueError, TypeError):
216
+ return False
217
+
218
+
219
+ def _slugify(text: str) -> str:
220
+ """Convert a title into a safe memory ID."""
221
+ import re
222
+ slug = re.sub(r"[^a-zA-Z0-9\s]", "", text).strip().lower()
223
+ slug = re.sub(r"\s+", "-", slug)
224
+ return slug[:40] or "memory"
225
+
226
+
227
+ def _find_entry(board: MemoryBoard, entry_id: str) -> MemoryEntry | None:
228
+ for e in board.forced + board.community:
229
+ if e.id == entry_id:
230
+ return e
231
+ return None
232
+
233
+
234
+ def _remove_entry(board: MemoryBoard, entry_id: str) -> bool:
235
+ for source in (board.forced, board.community):
236
+ for i, e in enumerate(source):
237
+ if e.id == entry_id:
238
+ source.pop(i)
239
+ return True
240
+ return False
@@ -0,0 +1,56 @@
1
+ """`hoja project ...` commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from typing import Optional
7
+
8
+ import typer
9
+
10
+ from hoja.cli_state import build_client, get_state
11
+ from hoja.graphql import queries
12
+ from hoja.models.project import Project
13
+ from hoja.output import render
14
+
15
+ project_app = typer.Typer(help="Projects")
16
+
17
+
18
+ @project_app.command("list")
19
+ def list_projects(
20
+ state_filter: Optional[str] = typer.Option(
21
+ None, "--state", help="started|planned|completed|paused|backlog|canceled"
22
+ ),
23
+ limit: int = typer.Option(50, "--limit", "-n"),
24
+ ) -> None:
25
+ """List projects."""
26
+ asyncio.run(_run_list(state_filter, limit))
27
+
28
+
29
+ @project_app.command("get")
30
+ def get_project(id: str = typer.Argument(..., help="Project UUID")) -> None:
31
+ """Get a project by UUID."""
32
+ asyncio.run(_run_get(id))
33
+
34
+
35
+ async def _run_list(state_filter: str | None, limit: int) -> None:
36
+ state = get_state()
37
+ filt: dict | None = None
38
+ if state_filter:
39
+ filt = {"state": {"eq": state_filter}}
40
+ async with build_client(state) as client:
41
+ projects = await client.execute_list(
42
+ Project, queries.PROJECTS_LIST,
43
+ {"filter": filt, "first": limit},
44
+ path=["projects"], operation="Projects",
45
+ )
46
+ render(projects, fmt=state.output, title="Projects")
47
+
48
+
49
+ async def _run_get(id: str) -> None:
50
+ state = get_state()
51
+ async with build_client(state) as client:
52
+ project = await client.execute_as(
53
+ Project, queries.PROJECT_BY_ID, {"id": id},
54
+ path=["project"], operation="Project",
55
+ )
56
+ render(project, fmt=state.output, title=project.name)
hoja/commands/raw.py ADDED
@@ -0,0 +1,50 @@
1
+ """`hoja raw query ...` — escape hatch for arbitrary GraphQL."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ import sys
8
+ from pathlib import Path
9
+ from typing import Optional
10
+
11
+ import typer
12
+
13
+ from hoja.cli_state import build_client, get_state
14
+
15
+ raw_app = typer.Typer(help="Escape hatch: arbitrary GraphQL")
16
+
17
+
18
+ @raw_app.command("query")
19
+ def raw_query(
20
+ query: Optional[str] = typer.Argument(None, help="GraphQL query string. If omitted, reads from stdin."),
21
+ file: Optional[Path] = typer.Option(None, "--file", "-f", help="Read query from file"),
22
+ variables: Optional[str] = typer.Option(None, "--variables", help="JSON variables"),
23
+ variables_file: Optional[Path] = typer.Option(None, "--variables-file", help="Read variables from JSON file"),
24
+ ) -> None:
25
+ """Execute an arbitrary GraphQL query/mutation."""
26
+ if file:
27
+ q = file.read_text()
28
+ elif query:
29
+ q = query
30
+ else:
31
+ q = sys.stdin.read()
32
+
33
+ if not q.strip():
34
+ typer.echo("error: empty query", err=True)
35
+ raise typer.Exit(2)
36
+
37
+ v: dict | None = None
38
+ if variables_file:
39
+ v = json.loads(variables_file.read_text())
40
+ elif variables:
41
+ v = json.loads(variables)
42
+
43
+ asyncio.run(_run(q, v))
44
+
45
+
46
+ async def _run(query: str, variables: dict | None) -> None:
47
+ state = get_state()
48
+ async with build_client(state) as client:
49
+ data = await client.execute(query, variables)
50
+ print(json.dumps(data, indent=2, default=str))
hoja/commands/team.py ADDED
@@ -0,0 +1,102 @@
1
+ """`hoja team ...` commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+
7
+ import typer
8
+
9
+ from hoja.cli_state import build_client, get_state
10
+ from hoja.errors import NotFoundError
11
+ from hoja.graphql import queries
12
+ from hoja.models.team import Team
13
+ from hoja.models.user import User
14
+ from hoja.models.workflow import WorkflowState
15
+ from hoja.output import render
16
+
17
+ team_app = typer.Typer(help="Teams")
18
+
19
+
20
+ @team_app.command("list")
21
+ def list_teams() -> None:
22
+ """List all teams in the workspace."""
23
+ asyncio.run(_run_list())
24
+
25
+
26
+ @team_app.command("get")
27
+ def get_team(key: str = typer.Argument(..., help="Team key (e.g. ENG) or UUID")) -> None:
28
+ """Get a single team by key or id."""
29
+ asyncio.run(_run_get(key))
30
+
31
+
32
+ @team_app.command("states")
33
+ def team_states(
34
+ key: str = typer.Argument(..., help="Team key or UUID"),
35
+ ) -> None:
36
+ """List workflow states for a team."""
37
+ asyncio.run(_run_states(key))
38
+
39
+
40
+ @team_app.command("members")
41
+ def team_members(
42
+ key: str = typer.Argument(..., help="Team key or UUID"),
43
+ ) -> None:
44
+ """List members of a team."""
45
+ asyncio.run(_run_members(key))
46
+
47
+
48
+ # ---------- runners ----------
49
+
50
+ async def _resolve_team_id(client, key: str) -> str:
51
+ if len(key) == 36 and key.count("-") == 4:
52
+ return key
53
+ teams = await client.execute_list(
54
+ Team, queries.TEAM_BY_KEY, {"key": key.upper()},
55
+ path=["teams"], operation="TeamByKey",
56
+ )
57
+ if not teams:
58
+ raise NotFoundError(f"No team with key {key!r}")
59
+ return teams[0].id
60
+
61
+
62
+ async def _run_list() -> None:
63
+ state = get_state()
64
+ async with build_client(state) as client:
65
+ teams = await client.execute_list(
66
+ Team, queries.TEAMS, path=["teams"], operation="Teams"
67
+ )
68
+ render(teams, fmt=state.output, title="Teams")
69
+
70
+
71
+ async def _run_get(key: str) -> None:
72
+ state = get_state()
73
+ async with build_client(state) as client:
74
+ teams = await client.execute_list(
75
+ Team, queries.TEAM_BY_KEY, {"key": key.upper()},
76
+ path=["teams"], operation="TeamByKey",
77
+ )
78
+ if not teams:
79
+ raise NotFoundError(f"No team with key {key!r}")
80
+ render(teams[0], fmt=state.output)
81
+
82
+
83
+ async def _run_states(key: str) -> None:
84
+ state = get_state()
85
+ async with build_client(state) as client:
86
+ team_id = await _resolve_team_id(client, key)
87
+ states = await client.execute_list(
88
+ WorkflowState, queries.TEAM_STATES, {"teamId": team_id},
89
+ path=["team", "states"], operation="TeamStates",
90
+ )
91
+ render(states, fmt=state.output, title=f"States for {key}")
92
+
93
+
94
+ async def _run_members(key: str) -> None:
95
+ state = get_state()
96
+ async with build_client(state) as client:
97
+ team_id = await _resolve_team_id(client, key)
98
+ members = await client.execute_list(
99
+ User, queries.TEAM_MEMBERS, {"teamId": team_id},
100
+ path=["team", "members"], operation="TeamMembers",
101
+ )
102
+ render(members, fmt=state.output, title=f"Members of {key}")
@@ -0,0 +1,162 @@
1
+ """`hoja update` — check PyPI and upgrade to the latest version."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json as _json
6
+ import shutil
7
+ import subprocess
8
+ import sys
9
+ import urllib.request
10
+ from typing import Any
11
+
12
+ import typer
13
+
14
+ from hoja import DISTRIBUTION_NAME, __version__
15
+ from hoja.cli_state import get_state
16
+ from hoja.errors import UsageError
17
+ from hoja.models.enums import OutputFormat
18
+
19
+ update_app = typer.Typer(help="Update hoja to the latest version")
20
+
21
+
22
+ PYPI_JSON_URL = f"https://pypi.org/pypi/{DISTRIBUTION_NAME}/json"
23
+
24
+
25
+ @update_app.callback(invoke_without_command=True)
26
+ def update(
27
+ ctx: typer.Context,
28
+ dry_run: bool = typer.Option(False, "--dry-run", help="Show what would happen without upgrading"),
29
+ yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation prompt"),
30
+ ) -> None:
31
+ """Check PyPI for a newer version and upgrade hoja."""
32
+ if ctx.invoked_subcommand is not None:
33
+ return
34
+
35
+ state = get_state()
36
+ current = __version__
37
+
38
+ try:
39
+ latest = _fetch_latest_version()
40
+ except Exception as e:
41
+ if state.output == OutputFormat.JSON:
42
+ print(_json.dumps({"error": str(e), "current": current}, indent=2))
43
+ else:
44
+ typer.echo(f"Could not check for updates: {e}", err=True)
45
+ raise typer.Exit(1)
46
+
47
+ needs_update = _version_compare(current, latest) < 0
48
+
49
+ manager = _detect_package_manager()
50
+ if manager == "pipx":
51
+ cmd = ["pipx", "upgrade", DISTRIBUTION_NAME]
52
+ else:
53
+ cmd = [sys.executable, "-m", "pip", "install", "--upgrade", DISTRIBUTION_NAME]
54
+
55
+ result: dict[str, Any] = {
56
+ "current": current,
57
+ "latest": latest,
58
+ "needs_update": needs_update,
59
+ "manager": manager,
60
+ "command": " ".join(cmd),
61
+ }
62
+
63
+ if state.output == OutputFormat.JSON:
64
+ print(_json.dumps(result, indent=2))
65
+ if needs_update and not dry_run:
66
+ _run_upgrade(cmd, yes=yes)
67
+ return
68
+
69
+ # Human output
70
+ if not needs_update:
71
+ typer.echo(f"hoja is up to date (v{current})")
72
+ return
73
+
74
+ typer.echo(f"Current: v{current}")
75
+ typer.echo(f"Latest: v{latest}")
76
+
77
+ if dry_run:
78
+ typer.echo(f"Would run: {' '.join(cmd)}")
79
+ return
80
+
81
+ if not yes:
82
+ confirmed = typer.confirm("Upgrade now?")
83
+ if not confirmed:
84
+ typer.echo("Aborted.")
85
+ raise typer.Exit(0)
86
+
87
+ _run_upgrade(cmd)
88
+
89
+
90
+ def _fetch_latest_version() -> str:
91
+ """Query PyPI JSON API for the latest release version."""
92
+ req = urllib.request.Request(
93
+ PYPI_JSON_URL,
94
+ headers={"Accept": "application/json", "User-Agent": "hoja/update"},
95
+ )
96
+ with urllib.request.urlopen(req, timeout=10) as resp: # noqa: S310
97
+ data = _json.load(resp)
98
+ info = data.get("info", {})
99
+ version = info.get("version")
100
+ if not version:
101
+ raise UsageError("Could not parse PyPI response", hint="Try again later.")
102
+ return version
103
+
104
+
105
+ def _version_compare(a: str, b: str) -> int:
106
+ """Compare two PEP 440 version strings. Returns <0 if a < b, 0 if equal, >0 if a > b."""
107
+ # Simple tuple comparison for x.y.z (good enough for this CLI)
108
+ def _parse(v: str) -> tuple[int, ...]:
109
+ parts = v.split(".")
110
+ result: list[int] = []
111
+ for p in parts:
112
+ # Strip any suffix like "a1", "b2", "rc1", "dev0"
113
+ num = ""
114
+ for ch in p:
115
+ if ch.isdigit():
116
+ num += ch
117
+ else:
118
+ break
119
+ result.append(int(num) if num else 0)
120
+ return tuple(result)
121
+
122
+ pa = _parse(a)
123
+ pb = _parse(b)
124
+ for x, y in zip(pa, pb):
125
+ if x != y:
126
+ return x - y
127
+ return len(pa) - len(pb)
128
+
129
+
130
+ def _detect_package_manager() -> str:
131
+ """Detect whether hoja was installed via pipx or pip."""
132
+ # Check if pipx is available and manages hoja
133
+ pipx_path = shutil.which("pipx")
134
+ if pipx_path:
135
+ try:
136
+ out = subprocess.run(
137
+ [pipx_path, "list", "--json"],
138
+ capture_output=True,
139
+ text=True,
140
+ timeout=5,
141
+ check=True,
142
+ )
143
+ data = _json.loads(out.stdout)
144
+ venvs = data.get("venvs", {})
145
+ # The current distribution is `hoja-cli`; `hoja` covers installs
146
+ # made with the pre-release name.
147
+ if DISTRIBUTION_NAME in venvs or "hoja" in venvs:
148
+ return "pipx"
149
+ except (subprocess.SubprocessError, _json.JSONDecodeError, FileNotFoundError):
150
+ pass
151
+ return "pip"
152
+
153
+
154
+ def _run_upgrade(cmd: list[str], *, yes: bool = True) -> None:
155
+ """Execute the upgrade command."""
156
+ try:
157
+ subprocess.run(cmd, check=True) # noqa: S603
158
+ except subprocess.CalledProcessError as e:
159
+ raise UsageError(
160
+ f"Upgrade failed (exit {e.returncode})",
161
+ hint="Try running the command manually: " + " ".join(cmd),
162
+ ) from e