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.
- VERSION +1 -0
- google_workspace/__init__.py +19 -0
- google_workspace/client.py +253 -0
- google_workspace/config.py +39 -0
- google_workspace/errors.py +19 -0
- google_workspace/mcp_server.py +100 -0
- google_workspace/oauth.py +131 -0
- google_workspace/principal.py +83 -0
- google_workspace/py.typed +0 -0
- google_workspace/tools.py +64 -0
- google_workspace/writes.py +44 -0
- hoja/__init__.py +29 -0
- hoja/__main__.py +6 -0
- hoja/auth.py +42 -0
- hoja/cli.py +161 -0
- hoja/cli_state.py +55 -0
- hoja/client.py +268 -0
- hoja/commands/__init__.py +4 -0
- hoja/commands/auth_cmd.py +271 -0
- hoja/commands/comment.py +149 -0
- hoja/commands/cycle.py +75 -0
- hoja/commands/init.py +108 -0
- hoja/commands/issue.py +398 -0
- hoja/commands/label.py +163 -0
- hoja/commands/memory.py +240 -0
- hoja/commands/project.py +56 -0
- hoja/commands/raw.py +50 -0
- hoja/commands/team.py +102 -0
- hoja/commands/update.py +162 -0
- hoja/config.py +362 -0
- hoja/errors.py +107 -0
- hoja/filters.py +150 -0
- hoja/graphql/__init__.py +5 -0
- hoja/graphql/fragments.py +138 -0
- hoja/graphql/mutations.py +160 -0
- hoja/graphql/queries.py +245 -0
- hoja/mcp/__init__.py +27 -0
- hoja/mcp/content.py +151 -0
- hoja/mcp/prompts.py +298 -0
- hoja/mcp/resources.py +213 -0
- hoja/mcp/server.py +189 -0
- hoja/memory_board.py +370 -0
- hoja/models/__init__.py +37 -0
- hoja/models/base.py +64 -0
- hoja/models/cycle.py +32 -0
- hoja/models/enums.py +62 -0
- hoja/models/issue.py +99 -0
- hoja/models/label.py +17 -0
- hoja/models/project.py +60 -0
- hoja/models/team.py +31 -0
- hoja/models/user.py +32 -0
- hoja/models/workflow.py +65 -0
- hoja/output.py +410 -0
- hoja/skill_content/commands.md +222 -0
- hoja/skill_content/examples.md +193 -0
- hoja/skill_content/filters.md +101 -0
- hoja/skill_content/output-formats.md +92 -0
- hoja/skill_content/overview.md +123 -0
- hoja/skill_content/workflows.md +174 -0
- hoja_cli-0.21.4.dist-info/METADATA +279 -0
- hoja_cli-0.21.4.dist-info/RECORD +107 -0
- hoja_cli-0.21.4.dist-info/WHEEL +4 -0
- hoja_cli-0.21.4.dist-info/entry_points.txt +4 -0
- hoja_cli-0.21.4.dist-info/licenses/LICENSE +21 -0
- hoja_server/README.md +135 -0
- hoja_server/__init__.py +11 -0
- hoja_server/abac.py +116 -0
- hoja_server/agent_chat.py +280 -0
- hoja_server/agent_conversations.py +91 -0
- hoja_server/agent_durable.py +332 -0
- hoja_server/agent_prompt.py +117 -0
- hoja_server/agent_threads.py +96 -0
- hoja_server/agent_uploads.py +60 -0
- hoja_server/app.py +2189 -0
- hoja_server/cadence.py +231 -0
- hoja_server/cli.py +191 -0
- hoja_server/cloudflare_realtime.py +110 -0
- hoja_server/comment_policy.py +70 -0
- hoja_server/connector_actions.py +119 -0
- hoja_server/connectors_api.py +375 -0
- hoja_server/db.py +1816 -0
- hoja_server/denials.py +18 -0
- hoja_server/deployment.py +28 -0
- hoja_server/email_adapter.py +94 -0
- hoja_server/google_login.py +217 -0
- hoja_server/google_oidc.py +342 -0
- hoja_server/identity_transfer.py +210 -0
- hoja_server/mcp_oauth.py +623 -0
- hoja_server/mcp_tools.py +579 -0
- hoja_server/meeting_policy.py +179 -0
- hoja_server/meetings.py +1332 -0
- hoja_server/meetings_api.py +909 -0
- hoja_server/model_providers.py +205 -0
- hoja_server/realtime.py +690 -0
- hoja_server/resolvers.py +1281 -0
- hoja_server/schema.graphql +477 -0
- hoja_server/schema_sdl.py +8 -0
- hoja_server/secrets_box.py +39 -0
- hoja_server/signup_config.py +292 -0
- hoja_server/signup_gate.py +393 -0
- hoja_server/signup_policy.py +97 -0
- hoja_server/signup_store.py +577 -0
- hoja_server/store.py +2179 -0
- hoja_server/sync.py +249 -0
- hoja_server/tenant_context.py +131 -0
- hoja_server/turnstile.py +81 -0
- hoja_server/writer.py +1262 -0
hoja/commands/memory.py
ADDED
|
@@ -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
|
hoja/commands/project.py
ADDED
|
@@ -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}")
|
hoja/commands/update.py
ADDED
|
@@ -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
|