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,271 @@
1
+ """`hoja me` and `hoja auth ...` commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json as _json
7
+ from typing import Any, Optional
8
+
9
+ import typer
10
+
11
+ from hoja.auth import get_viewer
12
+ from hoja.cli_state import build_client, get_state
13
+ from hoja.client import LinearClient
14
+ from hoja.config import (
15
+ AccountConfig,
16
+ load_config,
17
+ resolve_workspace,
18
+ save_config,
19
+ )
20
+ from hoja.errors import AuthError, UsageError
21
+ from hoja.models.enums import OutputFormat
22
+ from hoja.output import render
23
+
24
+ me_app = typer.Typer(help="Show the currently-authenticated Linear user")
25
+ auth_app = typer.Typer(help="Authentication operations")
26
+
27
+
28
+ @me_app.callback(invoke_without_command=True)
29
+ def me(ctx: typer.Context) -> None:
30
+ """Show the currently-authenticated user (alias for `auth status`)."""
31
+ if ctx.invoked_subcommand is not None:
32
+ return
33
+ asyncio.run(_run_me())
34
+
35
+
36
+ @auth_app.command("status")
37
+ def status() -> None:
38
+ """Show the currently-authenticated user and token source."""
39
+ asyncio.run(_run_me())
40
+
41
+
42
+ @auth_app.command("whoami")
43
+ def whoami() -> None:
44
+ """Alias of `auth status`."""
45
+ asyncio.run(_run_me())
46
+
47
+
48
+ async def _run_me() -> None:
49
+ state = get_state()
50
+ async with build_client(state) as client:
51
+ viewer = await get_viewer(client)
52
+ render(viewer, fmt=state.output, title="Viewer")
53
+
54
+
55
+ @auth_app.command("accounts")
56
+ def accounts_list() -> None:
57
+ """List all configured accounts with default/workspace/current markers."""
58
+ state = get_state()
59
+ config = state.config
60
+ workspace = resolve_workspace()
61
+ workspace_account = (
62
+ config.workspaces.get(str(workspace)) if workspace else None
63
+ )
64
+
65
+ account_rows: list[dict[str, Any]] = []
66
+ for name, acc in config.accounts.items():
67
+ markers: list[str] = []
68
+ if name == config.defaults.default_account:
69
+ markers.append("default")
70
+ if name == workspace_account:
71
+ markers.append("workspace")
72
+ if name == state.account_name:
73
+ markers.append("current")
74
+ account_rows.append(
75
+ {
76
+ "name": name,
77
+ "org_name": acc.org_name or "(unknown)",
78
+ "token_source": "config" if acc.token else f"${acc.token_env}",
79
+ "teams": acc.teams,
80
+ "markers": markers,
81
+ }
82
+ )
83
+
84
+ if state.output == OutputFormat.JSON:
85
+ print(_json.dumps(account_rows, indent=2))
86
+ return
87
+
88
+ # Human output
89
+ if not account_rows:
90
+ typer.echo("No accounts configured.")
91
+ typer.echo("Run: hoja auth add <name> --token <token>")
92
+ return
93
+
94
+ typer.echo("Configured accounts:")
95
+ for row in account_rows:
96
+ marker_str = ""
97
+ if row["markers"]:
98
+ marker_str = " [" + ", ".join(row["markers"]) + "]"
99
+ typer.echo(f" • {row['name']}{marker_str}")
100
+ if row["org_name"] != "(unknown)":
101
+ typer.echo(f" org: {row['org_name']}")
102
+ typer.echo(f" token: {row['token_source']}")
103
+ if row["teams"]:
104
+ typer.echo(f" teams: {', '.join(row['teams'])}")
105
+ if workspace:
106
+ typer.echo(f"\nWorkspace: {workspace}")
107
+ else:
108
+ typer.echo("\nWorkspace: (none — not in a git repo)")
109
+
110
+
111
+ @auth_app.command("add")
112
+ def account_add(
113
+ name: str = typer.Argument(..., help="Account name"),
114
+ token: str = typer.Option(..., "--token", help="Hoja API token"),
115
+ token_env: str = typer.Option(
116
+ "HOJA_TOKEN", "--token-env", help="Environment variable name"
117
+ ),
118
+ verify: bool = typer.Option(
119
+ True, "--verify/--no-verify", help="Verify token by calling the API"
120
+ ),
121
+ default: bool = typer.Option(
122
+ False, "--default", help="Set as global default account"
123
+ ),
124
+ teams: Optional[str] = typer.Option(
125
+ None,
126
+ "--teams",
127
+ help="Comma-separated team keys this account owns (e.g. 'SWA,ENG'). "
128
+ "Enables automatic account selection when a command targets one of "
129
+ "these teams.",
130
+ ),
131
+ ) -> None:
132
+ """Add a new named account."""
133
+ config = load_config()
134
+ if name in config.accounts:
135
+ raise UsageError(
136
+ f"Account '{name}' already exists",
137
+ hint="Use 'hoja auth remove {name}' first, or pick a different name.",
138
+ )
139
+
140
+ org_name: str | None = None
141
+ if verify:
142
+ try:
143
+ org_name = asyncio.run(_fetch_org_name(token))
144
+ except Exception as e:
145
+ typer.echo(f"Warning: could not verify token ({e})", err=True)
146
+
147
+ team_keys = (
148
+ [t.strip().upper() for t in teams.split(",") if t.strip()] if teams else []
149
+ )
150
+ was_empty = len(config.accounts) == 0
151
+ config.accounts[name] = AccountConfig(
152
+ # The shared default env vars are never persisted; only a distinct
153
+ # per-account variable stores the token inline.
154
+ token=token if token_env in ("HOJA_TOKEN", "LINEAR_TOKEN") else None,
155
+ token_env=token_env,
156
+ org_name=org_name,
157
+ teams=team_keys,
158
+ )
159
+ if default or was_empty:
160
+ config.defaults.default_account = name
161
+ save_config(config)
162
+ typer.echo(f"Added account '{name}'" + (f" ({org_name})" if org_name else ""))
163
+
164
+
165
+ async def _fetch_org_name(token: str) -> str | None:
166
+ """Make a quick API call to get the organization name."""
167
+ from hoja.graphql import queries
168
+ from hoja.models.user import User
169
+
170
+ client = LinearClient(token=token, timeout=10.0)
171
+ async with client:
172
+ viewer = await client.execute_as(User, queries.VIEWER, path=["viewer"], operation="Viewer")
173
+ # viewer.organization may not exist on the User model; handle gracefully
174
+ return getattr(viewer, "organization_name", None) or getattr(
175
+ viewer, "organization", None
176
+ )
177
+
178
+
179
+ @auth_app.command("switch")
180
+ def account_switch(
181
+ name: str = typer.Argument(..., help="Account name to set as default"),
182
+ ) -> None:
183
+ """Set the global default account."""
184
+ config = load_config()
185
+ if name not in config.accounts:
186
+ raise AuthError(
187
+ f"Account '{name}' not found",
188
+ hint="Run 'hoja auth accounts' to see available accounts.",
189
+ )
190
+ config.defaults.default_account = name
191
+ save_config(config)
192
+ typer.echo(f"Default account set to '{name}'")
193
+
194
+
195
+ @auth_app.command("teams")
196
+ def account_teams(
197
+ name: str = typer.Argument(..., help="Account name to edit"),
198
+ teams: str = typer.Argument(
199
+ ...,
200
+ help="Comma-separated team keys this account owns (e.g. 'SWA,ENG'). "
201
+ "Pass an empty string to clear.",
202
+ ),
203
+ ) -> None:
204
+ """Set the team keys an account owns (for automatic account selection)."""
205
+ config = load_config()
206
+ if name not in config.accounts:
207
+ raise AuthError(
208
+ f"Account '{name}' not found",
209
+ hint="Run 'hoja auth accounts' to see available accounts.",
210
+ )
211
+ team_keys = [t.strip().upper() for t in teams.split(",") if t.strip()]
212
+ config.accounts[name].teams = team_keys
213
+ save_config(config)
214
+ if team_keys:
215
+ typer.echo(f"Account '{name}' now owns teams: {', '.join(team_keys)}")
216
+ else:
217
+ typer.echo(f"Cleared team ownership for account '{name}'")
218
+
219
+
220
+ @auth_app.command("remove")
221
+ def account_remove(
222
+ name: str = typer.Argument(..., help="Account name to remove"),
223
+ ) -> None:
224
+ """Remove a named account."""
225
+ config = load_config()
226
+ if name not in config.accounts:
227
+ raise AuthError(
228
+ f"Account '{name}' not found",
229
+ hint="Run 'hoja auth accounts' to see available accounts.",
230
+ )
231
+ del config.accounts[name]
232
+ # Clean up workspace mappings pointing to this account
233
+ config.workspaces = {
234
+ k: v for k, v in config.workspaces.items() if v != name
235
+ }
236
+ if config.defaults.default_account == name:
237
+ config.defaults.default_account = next(iter(config.accounts), None)
238
+ save_config(config)
239
+ typer.echo(f"Removed account '{name}'")
240
+
241
+
242
+ @auth_app.command("workspace")
243
+ def auth_workspace() -> None:
244
+ """Show current workspace detection and mapped account."""
245
+ state = get_state()
246
+ config = state.config
247
+ workspace = resolve_workspace()
248
+
249
+ result: dict[str, Any] = {
250
+ "workspace": str(workspace) if workspace else None,
251
+ "mapped_account": None,
252
+ "default_account": config.defaults.default_account,
253
+ }
254
+ if workspace:
255
+ mapped = config.workspaces.get(str(workspace))
256
+ result["mapped_account"] = mapped
257
+
258
+ if state.output == OutputFormat.JSON:
259
+ print(_json.dumps(result, indent=2))
260
+ return
261
+
262
+ if workspace:
263
+ typer.echo(f"Workspace: {workspace}")
264
+ if mapped := result["mapped_account"]:
265
+ typer.echo(f"Mapped account: {mapped}")
266
+ else:
267
+ typer.echo("No workspace mapping configured.")
268
+ typer.echo(f"Default account: {config.defaults.default_account or '(none)'}")
269
+ else:
270
+ typer.echo("Not inside a git repository.")
271
+ typer.echo(f"Default account: {config.defaults.default_account or '(none)'}")
@@ -0,0 +1,149 @@
1
+ """`hoja comment ...` commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import sys
7
+ from typing import Optional
8
+
9
+ import typer
10
+
11
+ from hoja.cli_state import build_client, get_state
12
+ from hoja.errors import UsageError
13
+ from hoja.graphql import mutations, queries
14
+ from hoja.models.issue import Comment, Issue
15
+ from hoja.output import render
16
+
17
+ comment_app = typer.Typer(help="Comments")
18
+
19
+
20
+ @comment_app.command("list")
21
+ def list_comments(
22
+ issue_id: str = typer.Argument(..., help="Issue identifier (ENG-34) or UUID"),
23
+ limit: int = typer.Option(50, "--limit", "-n"),
24
+ ) -> None:
25
+ """List comments on an issue."""
26
+ asyncio.run(_run_list(issue_id, limit))
27
+
28
+
29
+ @comment_app.command("add")
30
+ def add_comment(
31
+ issue_id: str = typer.Argument(..., help="Issue identifier or UUID"),
32
+ body: Optional[str] = typer.Argument(None, help="Comment body. If omitted, reads stdin."),
33
+ ) -> None:
34
+ """Add a comment to an issue. Reads stdin if body argument is omitted."""
35
+ text = body if body is not None else sys.stdin.read()
36
+ if not text.strip():
37
+ raise UsageError("Empty comment body — provide an argument or pipe content")
38
+ asyncio.run(_run_add(issue_id, text))
39
+
40
+
41
+ @comment_app.command("edit")
42
+ def edit_comment(
43
+ comment_id: str = typer.Argument(..., help="Comment UUID"),
44
+ body: Optional[str] = typer.Argument(None, help="New body. If omitted, reads stdin."),
45
+ ) -> None:
46
+ """Edit an existing comment."""
47
+ text = body if body is not None else sys.stdin.read()
48
+ if not text.strip():
49
+ raise UsageError("Empty body")
50
+ asyncio.run(_run_edit(comment_id, text))
51
+
52
+
53
+ @comment_app.command("delete")
54
+ def delete_comment(
55
+ comment_id: str = typer.Argument(..., help="Comment UUID"),
56
+ ) -> None:
57
+ """Delete a comment."""
58
+ asyncio.run(_run_delete(comment_id))
59
+
60
+
61
+ # ---------- runners ----------
62
+
63
+ async def _resolve_issue_uuid(client, ref: str) -> str:
64
+ """Resolve identifier like ENG-34 to a UUID; pass through if already UUID."""
65
+ if len(ref) == 36 and ref.count("-") == 4:
66
+ return ref
67
+ issue = await client.execute_as(
68
+ Issue, queries.ISSUE_BY_IDENTIFIER, {"id": ref},
69
+ path=["issue"], operation="IssueByIdentifier",
70
+ )
71
+ return issue.id
72
+
73
+
74
+ async def _run_list(issue_ref: str, limit: int) -> None:
75
+ state = get_state()
76
+ async with build_client(state) as client:
77
+ data = await client.execute(
78
+ queries.ISSUE_COMMENTS,
79
+ {"issueId": issue_ref, "first": limit},
80
+ operation="IssueComments",
81
+ )
82
+ issue_data = data.get("issue") or {}
83
+ nodes = (issue_data.get("comments") or {}).get("nodes") or []
84
+ comments = [Comment.model_validate(n) for n in nodes]
85
+ render(
86
+ comments,
87
+ fmt=state.output,
88
+ title=f"Comments on {issue_data.get('identifier', issue_ref)}",
89
+ )
90
+
91
+
92
+ async def _run_add(issue_ref: str, body: str) -> None:
93
+ state = get_state()
94
+ async with build_client(state) as client:
95
+ issue_uuid = await _resolve_issue_uuid(client, issue_ref)
96
+ input_ = {"issueId": issue_uuid, "body": body.strip()}
97
+ if state.dry_run:
98
+ from hoja.output import emit_error
99
+ emit_error("dry-run: not executing", hint=str({"input": input_}))
100
+ return
101
+ data = await client.execute(
102
+ mutations.COMMENT_CREATE, {"input": input_}, operation="CommentCreate"
103
+ )
104
+ payload = data.get("commentCreate", {})
105
+ if not payload.get("success"):
106
+ raise UsageError("commentCreate returned success=false")
107
+ c = Comment.model_validate(payload["comment"])
108
+ render(c, fmt=state.output, title="Comment added")
109
+
110
+
111
+ async def _run_edit(comment_id: str, body: str) -> None:
112
+ state = get_state()
113
+ async with build_client(state) as client:
114
+ input_ = {"body": body.strip()}
115
+ if state.dry_run:
116
+ from hoja.output import emit_error
117
+ emit_error("dry-run: not executing", hint=str({"id": comment_id, "input": input_}))
118
+ return
119
+ data = await client.execute(
120
+ mutations.COMMENT_UPDATE,
121
+ {"id": comment_id, "input": input_},
122
+ operation="CommentUpdate",
123
+ )
124
+ payload = data.get("commentUpdate", {})
125
+ if not payload.get("success"):
126
+ raise UsageError("commentUpdate returned success=false")
127
+ c = Comment.model_validate(payload["comment"])
128
+ render(c, fmt=state.output, title="Comment updated")
129
+
130
+
131
+ async def _run_delete(comment_id: str) -> None:
132
+ state = get_state()
133
+ async with build_client(state) as client:
134
+ if state.dry_run:
135
+ from hoja.output import emit_error
136
+ emit_error("dry-run: not executing", hint=f"delete comment {comment_id}")
137
+ return
138
+ data = await client.execute(
139
+ mutations.COMMENT_DELETE, {"id": comment_id}, operation="CommentDelete"
140
+ )
141
+ ok = data.get("commentDelete", {}).get("success")
142
+ if not ok:
143
+ raise UsageError("commentDelete returned success=false")
144
+ from hoja.models.enums import OutputFormat
145
+ if state.output == OutputFormat.JSON:
146
+ import json as _json
147
+ print(_json.dumps({"deleted": comment_id, "success": True}))
148
+ else:
149
+ print(f"Deleted comment {comment_id}")
hoja/commands/cycle.py ADDED
@@ -0,0 +1,75 @@
1
+ """`hoja cycle ...` 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.cycle import Cycle
13
+ from hoja.models.team import Team
14
+ from hoja.output import render
15
+
16
+ cycle_app = typer.Typer(help="Cycles (sprints)")
17
+
18
+
19
+ @cycle_app.command("current")
20
+ def current_cycle(team: str = typer.Argument(..., help="Team key (e.g. ENG)")) -> None:
21
+ """Show the currently-active cycle for a team."""
22
+ asyncio.run(_run_current(team))
23
+
24
+
25
+ @cycle_app.command("list")
26
+ def list_cycles(team: str = typer.Argument(..., help="Team key (e.g. ENG)")) -> None:
27
+ """List cycles for a team."""
28
+ asyncio.run(_run_list(team))
29
+
30
+
31
+ async def _resolve_team_id(client, key: str) -> str:
32
+ if len(key) == 36 and key.count("-") == 4:
33
+ return key
34
+ teams = await client.execute_list(
35
+ Team, queries.TEAM_BY_KEY, {"key": key.upper()},
36
+ path=["teams"], operation="TeamByKey",
37
+ )
38
+ if not teams:
39
+ raise NotFoundError(f"No team with key {key!r}")
40
+ return teams[0].id
41
+
42
+
43
+ async def _run_current(team: str) -> None:
44
+ state = get_state()
45
+ async with build_client(state) as client:
46
+ team_id = await _resolve_team_id(client, team)
47
+ data = await client.execute(
48
+ queries.TEAM_CYCLES, {"teamId": team_id}, operation="TeamCycles"
49
+ )
50
+ active = data.get("team", {}).get("activeCycle")
51
+ if not active:
52
+ # Graceful empty result — exit 0, but signal absence cleanly
53
+ from hoja.models.enums import OutputFormat
54
+ from hoja.output import emit_error
55
+ if state.output == OutputFormat.JSON:
56
+ import json as _json
57
+ print(_json.dumps({"active_cycle": None, "team": team}, indent=2))
58
+ elif state.output == OutputFormat.IDS:
59
+ pass # empty stdout — no IDs to print
60
+ else:
61
+ emit_error(f"No active cycle on team {team!r}",
62
+ hint="Run `hoja cycle list <team>` to see all cycles")
63
+ return
64
+ render(Cycle.model_validate(active), fmt=state.output, title="Active Cycle")
65
+
66
+
67
+ async def _run_list(team: str) -> None:
68
+ state = get_state()
69
+ async with build_client(state) as client:
70
+ team_id = await _resolve_team_id(client, team)
71
+ cycles = await client.execute_list(
72
+ Cycle, queries.TEAM_CYCLES, {"teamId": team_id},
73
+ path=["team", "cycles"], operation="TeamCycles",
74
+ )
75
+ render(cycles, fmt=state.output, title=f"Cycles ({team})")
hoja/commands/init.py ADDED
@@ -0,0 +1,108 @@
1
+ """`hoja init` — scaffold the config file at the standard location."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+
7
+ import typer
8
+
9
+ from hoja.config import config_path
10
+
11
+ init_app = typer.Typer(help="Initialize hoja configuration")
12
+
13
+
14
+ CONFIG_TEMPLATE = """\
15
+ # hoja configuration file
16
+ # Location: __CONFIG_PATH__
17
+ #
18
+ # Token resolution order (per account):
19
+ # 1. --token CLI flag
20
+ # 2. $<ACCOUNT_TOKEN_ENV> environment variable (default: HOJA_TOKEN;
21
+ # the deprecated LINEAR_TOKEN is read as a fallback)
22
+ # 3. [accounts.<name>].token below (discouraged: plaintext in dotfiles)
23
+ #
24
+ # Account resolution order:
25
+ # 1. --account CLI flag
26
+ # 2. Workspace-mapped account (git repo → account)
27
+ # 3. Global default account
28
+
29
+ [accounts.default]
30
+ # Environment variable to read the token from. Default: HOJA_TOKEN
31
+ # (LINEAR_TOKEN is still honoured as a deprecated fallback).
32
+ token_env = "HOJA_TOKEN"
33
+ # Uncomment to store the token here directly (not recommended):
34
+ # token = "lin_api_..."
35
+ # Team keys this account owns — enables automatic account selection when a
36
+ # command targets one of these teams (e.g. `hoja issue get SWA-20`).
37
+ # teams = ["SWA", "ENG"]
38
+
39
+ [defaults]
40
+ # Default team key — used when commands accept --team but you omit it.
41
+ # team = "ENG"
42
+ output = "human"
43
+ # editor = "$EDITOR"
44
+ # Global default account when not in a mapped workspace
45
+ default_account = "default"
46
+
47
+ [display]
48
+ color = true
49
+ table_max_width = 120
50
+ truncate_descriptions = 80
51
+
52
+ # Per-workspace account mappings (absolute repo path → account name).
53
+ # Detected automatically when you run `hoja auth workspace`.
54
+ # [workspaces]
55
+ # "/home/user/work/acme" = "work"
56
+ # "/home/user/oss/project" = "personal"
57
+
58
+ # Named filter aliases. Use with: hoja issue list --view <name>
59
+ # (Not yet wired up in v0.2 — reserved.)
60
+ [views]
61
+ # my-open-bugs = { assignee = "me", label = "Bug", "state.type" = "!completed" }
62
+ # team-blockers = { team = "ENG", priority = 1, "state.type" = "started" }
63
+ """
64
+
65
+
66
+ @init_app.callback(invoke_without_command=True)
67
+ def init(
68
+ ctx: typer.Context,
69
+ force: bool = typer.Option(False, "--force", "-f", help="Overwrite existing config"),
70
+ path: str | None = typer.Option(
71
+ None, "--path", help="Write to this path instead of the default"
72
+ ),
73
+ ) -> None:
74
+ """Create a starter config file at the standard location."""
75
+ if ctx.invoked_subcommand is not None:
76
+ return
77
+ target = config_path() if path is None else _to_path(path)
78
+ target.parent.mkdir(parents=True, exist_ok=True)
79
+ if target.exists() and not force:
80
+ typer.echo(
81
+ f"Config already exists at {target}. Use --force to overwrite.",
82
+ err=True,
83
+ )
84
+ raise typer.Exit(2)
85
+ target.write_text(
86
+ CONFIG_TEMPLATE.replace("__CONFIG_PATH__", str(target)),
87
+ encoding="utf-8",
88
+ )
89
+ try:
90
+ os.chmod(target, 0o600)
91
+ except OSError:
92
+ pass
93
+ typer.echo(f"Created {target}")
94
+ typer.echo("")
95
+ typer.echo("Next steps:")
96
+ typer.echo(f" 1. export HOJA_TOKEN=\"lin_api_...\" # or edit {target}")
97
+ typer.echo(" 2. hoja me # verify it works")
98
+ typer.echo("")
99
+ typer.echo("Multi-account setup:")
100
+ typer.echo(" hoja auth add work --token $HOJA_WORK_TOKEN")
101
+ typer.echo(" hoja auth add personal --token $HOJA_PERSONAL_TOKEN")
102
+ typer.echo(" hoja auth switch work")
103
+
104
+
105
+ def _to_path(p: str):
106
+ from pathlib import Path
107
+
108
+ return Path(p).expanduser().resolve()