lithora-cli 0.2.0__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.
@@ -0,0 +1,3 @@
1
+ """lithora-cli — the terminal-native, agent-grade control plane for Lithora."""
2
+
3
+ __version__ = "0.2.0"
File without changes
@@ -0,0 +1,41 @@
1
+ """Shared command helpers: client access + clean SDK-error → exit-code mapping."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from contextlib import contextmanager
6
+
7
+ import typer
8
+
9
+ from lithora import ApiError, AuthError, LithoraError
10
+ from ..output import error, exit_code_for, render
11
+
12
+
13
+ def client(ctx: typer.Context):
14
+ """The authenticated Lithora client built by the global callback."""
15
+ return ctx.obj["client"]
16
+
17
+
18
+ def out(ctx: typer.Context) -> str:
19
+ return ctx.obj.get("output", "table")
20
+
21
+
22
+ @contextmanager
23
+ def errors():
24
+ """Map SDK exceptions to a friendly stderr message + the right exit code."""
25
+ try:
26
+ yield
27
+ except AuthError as e:
28
+ detail = getattr(e, "detail", None) or str(e)
29
+ error("{} (run 'lithora login')".format(detail))
30
+ raise typer.Exit(exit_code_for(getattr(e, "status_code", 401)))
31
+ except ApiError as e:
32
+ error(str(getattr(e, "detail", None) or e))
33
+ raise typer.Exit(exit_code_for(getattr(e, "status_code", None)))
34
+ except LithoraError as e:
35
+ error(str(e))
36
+ raise typer.Exit(1)
37
+
38
+
39
+ def show(ctx: typer.Context, data, *, columns=None, title=None) -> None:
40
+ """Render a payload in the invocation's output mode."""
41
+ render(data, out(ctx), columns=columns, title=title)
@@ -0,0 +1,70 @@
1
+ """`lithora token` (PATs) and `lithora profile` (contexts)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import List, Optional
6
+
7
+ import typer
8
+
9
+ from .. import config
10
+ from ._common import client, errors, show
11
+ from ..output import success
12
+
13
+ # ---- token group (Personal Access Tokens) ---------------------------------
14
+ token_app = typer.Typer(no_args_is_help=True, help="Personal access tokens (scoped API keys).")
15
+
16
+
17
+ @token_app.command("create")
18
+ def token_create(
19
+ ctx: typer.Context,
20
+ name: str = typer.Option(..., "--name", "-n", help="Label for the token"),
21
+ scopes: Optional[List[str]] = typer.Option(None, "--scope", help="Repeatable, e.g. tasks:write"),
22
+ expires_in_days: Optional[int] = typer.Option(None, "--expires-in-days"),
23
+ ):
24
+ """Mint a scoped PAT (the secret is shown ONCE — store it securely)."""
25
+ with errors():
26
+ show(ctx, client(ctx).tokens.create(name, scopes=scopes or None, expires_in_days=expires_in_days))
27
+
28
+
29
+ @token_app.command("list")
30
+ def token_list(ctx: typer.Context):
31
+ """List your PATs (masked)."""
32
+ with errors():
33
+ show(ctx, client(ctx).tokens.list(),
34
+ columns=["token_id", "name", "scopes", "expires_at", "last_used_at"])
35
+
36
+
37
+ @token_app.command("revoke")
38
+ def token_revoke(ctx: typer.Context, token_id: str = typer.Argument(...)):
39
+ """Revoke a PAT immediately."""
40
+ with errors():
41
+ client(ctx).tokens.revoke(token_id)
42
+ success("Revoked {}".format(token_id))
43
+
44
+
45
+ # ---- profile group (contexts) ---------------------------------------------
46
+ profile_app = typer.Typer(no_args_is_help=True, help="Switch between accounts/orgs (contexts).")
47
+
48
+
49
+ @profile_app.command("list")
50
+ def profile_list(ctx: typer.Context):
51
+ """List configured profiles."""
52
+ cur = config.current_profile()
53
+ rows = [{"profile": ("* " + n) if n == cur else n, "base_url": p.get("base_url", "")}
54
+ for n, p in config.list_profiles().items()]
55
+ show(ctx, {"profiles": rows or [{"profile": "default", "base_url": config.DEFAULT_BASE_URL}]},
56
+ columns=["profile", "base_url"])
57
+
58
+
59
+ @profile_app.command("use")
60
+ def profile_use(name: str = typer.Argument(..., help="Profile name to switch to")):
61
+ """Set the active profile."""
62
+ config.use_profile(name)
63
+ success("Now using profile '{}'".format(name))
64
+
65
+
66
+ @profile_app.command("show")
67
+ def profile_show(ctx: typer.Context):
68
+ """Show the active profile + base URL (token not printed)."""
69
+ cfg = ctx.obj["config"]
70
+ show(ctx, {"profile": cfg.profile, "base_url": cfg.base_url, "authenticated": bool(cfg.token)})
@@ -0,0 +1,192 @@
1
+ """`lithora ai` — the confirmation-gated agent, in your terminal.
2
+
3
+ lithora ai "plan the Q3 launch into tasks" # propose → approve → apply
4
+ lithora ai pending # staged overnight-triage plans
5
+ lithora ai confirm <action_id> --session <sid> # approve a staged plan
6
+ lithora ai sessions # your AI sessions
7
+
8
+ The agent NEVER writes without approval: chat returns a PLAN; you approve it here.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from typing import Optional
14
+
15
+ import typer
16
+ from typer.core import TyperGroup
17
+
18
+ try: # standard Typer depends on the real click…
19
+ import click # type: ignore
20
+ except ImportError: # …typer-slim vendors it as typer._click
21
+ from typer import _click as click # type: ignore
22
+
23
+ from ._common import client, errors, out, show
24
+ from ..output import error, is_tty, print_json, success, EXIT_USAGE
25
+
26
+
27
+ class _AiGroup(TyperGroup):
28
+ """Route a bare prompt to ``chat`` so ``lithora ai "plan the launch"`` works.
29
+
30
+ Real subcommands (chat/pending/confirm/sessions) still resolve normally; only
31
+ when the first token isn't one of them do we treat the whole arg list as a
32
+ chat message — so the natural-language shortcut and the structured commands
33
+ coexist without one stealing the other.
34
+ """
35
+
36
+ def resolve_command(self, ctx, args):
37
+ try:
38
+ return super().resolve_command(ctx, args)
39
+ except click.exceptions.UsageError:
40
+ chat_cmd = self.get_command(ctx, "chat")
41
+ if chat_cmd is None: # pragma: no cover - chat is always registered
42
+ raise
43
+ return chat_cmd.name, chat_cmd, args
44
+
45
+
46
+ app = typer.Typer(
47
+ cls=_AiGroup,
48
+ no_args_is_help=False,
49
+ invoke_without_command=True,
50
+ # Tolerate a leading flag before the prompt (e.g. `ai --yes "do it"`); the
51
+ # bare prompt itself is rerouted to `chat` by _AiGroup.resolve_command.
52
+ context_settings={"allow_extra_args": True, "ignore_unknown_options": True},
53
+ help="Talk to the confirmation-gated Lithora agent.",
54
+ )
55
+
56
+
57
+ def _render_plan(plan: dict) -> None:
58
+ """Pretty-print a staged plan (summary + planned actions)."""
59
+ typer.secho("\nACTION PLAN (requires confirmation)", fg=typer.colors.YELLOW, bold=True)
60
+ typer.echo(" Action: {} Status: {}".format(
61
+ plan.get("action_id", "?"), plan.get("state", "waiting_confirmation")))
62
+ for i, a in enumerate((plan.get("planned_actions") or []), 1):
63
+ kind = a.get("type", "action")
64
+ if kind == "task":
65
+ gh = ""
66
+ if a.get("github_repo"):
67
+ gh = " [{}{}]".format(a["github_repo"],
68
+ "#{}".format(a["github_issue_number"]) if a.get("github_issue_number") else "")
69
+ typer.echo(" {}. + task: {}{}".format(i, a.get("title", "?"), gh))
70
+ for s in (a.get("subtasks") or []):
71
+ typer.echo(" - subtask: {}".format(s.get("title", "?")))
72
+ elif kind in ("subtask", "note"):
73
+ typer.echo(" {}. + {}: {}".format(i, kind, a.get("title", "?")))
74
+ else:
75
+ typer.echo(" {}. {}".format(i, a.get("label") or a.get("tool") or kind))
76
+ typer.echo(" Summary: {}".format(plan.get("summary", "")))
77
+
78
+
79
+ def _apply(ctx, session_id: str, action_id: str, confirmed: bool) -> None:
80
+ res = client(ctx).ai.confirm_agent_action(session_id, action_id, confirmed)
81
+ if out(ctx) == "json":
82
+ print_json(res)
83
+ return
84
+ if not confirmed:
85
+ success("Discarded — nothing was written.")
86
+ return
87
+ success(res.get("response", "Applied."))
88
+ for inv in (res.get("action_plan", {}) or {}).get("tool_invocations", []):
89
+ ok = "✓" if inv.get("success") else "✗"
90
+ typer.echo(" {} {}".format(ok, inv.get("function")))
91
+
92
+
93
+ @app.callback(invoke_without_command=True)
94
+ def ai_main(ctx: typer.Context):
95
+ """Send a message; if the agent proposes changes, approve them in the terminal.
96
+
97
+ `lithora ai "<prompt>"` is a shortcut for `lithora ai chat "<prompt>"` —
98
+ a bare prompt is routed to the chat subcommand (see _AiGroup), so flags like
99
+ --yes / --session work the same either way.
100
+ """
101
+ if ctx.invoked_subcommand is None:
102
+ typer.echo(ctx.get_help())
103
+ raise typer.Exit(0)
104
+
105
+
106
+ @app.command("chat")
107
+ def chat(
108
+ ctx: typer.Context,
109
+ prompt: str = typer.Argument(..., help="Your message to the agent"),
110
+ session: Optional[str] = typer.Option(None, "--session", "-s", help="Existing session id"),
111
+ yes: bool = typer.Option(False, "--yes", "-y", help="Auto-approve the plan (CI)"),
112
+ ):
113
+ """Chat with the agent (full form, with --yes / --session)."""
114
+ _do_chat(ctx, prompt, session=session, yes=yes)
115
+
116
+
117
+ def _do_chat(ctx: typer.Context, prompt: str, session: Optional[str], yes: bool) -> None:
118
+ with errors():
119
+ c = client(ctx)
120
+ sid = session or c.ai.create_session().get("session_id")
121
+ resp = c.ai.chat(sid, prompt)
122
+
123
+ if out(ctx) == "json":
124
+ print_json(resp)
125
+ # In JSON mode, still honor --yes so CI can one-shot.
126
+ plan = resp.get("action_plan") or {}
127
+ if resp.get("requires_confirmation") and plan.get("action_id") and yes:
128
+ _apply(ctx, sid, plan["action_id"], True)
129
+ return
130
+
131
+ plan = resp.get("action_plan") or {}
132
+ if not (resp.get("requires_confirmation") and plan.get("action_id")):
133
+ # plain answer (no mutations proposed)
134
+ typer.echo(resp.get("response", ""))
135
+ if not session:
136
+ typer.secho("\n(session: {})".format(sid), dim=True)
137
+ return
138
+
139
+ _render_plan(plan)
140
+ action_id = plan["action_id"]
141
+
142
+ if yes:
143
+ typer.echo("\n--yes → approving.")
144
+ _apply(ctx, sid, action_id, True)
145
+ return
146
+ if not is_tty():
147
+ error("This plan requires confirmation but stdin is not a TTY. "
148
+ "Re-run with --yes, or: lithora ai confirm {} --session {}".format(action_id, sid))
149
+ raise typer.Exit(EXIT_USAGE)
150
+
151
+ choice = typer.prompt("\nApprove? [y]es / [n]o", default="n").strip().lower()
152
+ _apply(ctx, sid, action_id, choice in ("y", "yes"))
153
+ if not session:
154
+ typer.secho("(session: {})".format(sid), dim=True)
155
+
156
+
157
+ @app.command("pending")
158
+ def pending(ctx: typer.Context):
159
+ """List staged-but-unapproved agent plans (e.g. overnight triage)."""
160
+ with errors():
161
+ data = client(ctx).ai.list_pending_actions()
162
+ if out(ctx) == "json":
163
+ print_json(data)
164
+ return
165
+ items = data.get("pending", []) if isinstance(data, dict) else (data or [])
166
+ if not items:
167
+ typer.echo("No staged plans awaiting approval.")
168
+ return
169
+ for p in items:
170
+ _render_plan(p)
171
+ typer.echo(" approve: lithora ai confirm {} --session {}\n".format(
172
+ p.get("action_id"), p.get("session_id")))
173
+
174
+
175
+ @app.command("confirm")
176
+ def confirm(
177
+ ctx: typer.Context,
178
+ action_id: str = typer.Argument(...),
179
+ session: str = typer.Option(..., "--session", "-s"),
180
+ reject: bool = typer.Option(False, "--reject", help="Reject instead of approve"),
181
+ ):
182
+ """Approve (or --reject) a staged agent plan by id."""
183
+ with errors():
184
+ _apply(ctx, session, action_id, not reject)
185
+
186
+
187
+ @app.command("sessions")
188
+ def sessions(ctx: typer.Context):
189
+ """List your AI sessions."""
190
+ with errors():
191
+ show(ctx, client(ctx).ai.list_sessions(),
192
+ columns=["session_id", "title", "updated_at"])
@@ -0,0 +1,112 @@
1
+ """`lithora automations` — workflows as code (trigger → condition → action) + runs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Optional
6
+
7
+ import typer
8
+
9
+ from ._common import client, errors, out, show
10
+
11
+ app = typer.Typer(no_args_is_help=True, help="Manage automations and their runs.")
12
+
13
+
14
+ @app.command("list")
15
+ def list_automations(ctx: typer.Context):
16
+ """List automations."""
17
+ with errors():
18
+ show(ctx, client(ctx).automations.list(),
19
+ columns=["automation_id", "name", "trigger_type", "is_active"])
20
+
21
+
22
+ @app.command("show")
23
+ def show_automation(ctx: typer.Context, automation_id: str = typer.Argument(...)):
24
+ """Show one automation (trigger/condition/actions)."""
25
+ with errors():
26
+ show(ctx, client(ctx).automations.get(automation_id))
27
+
28
+
29
+ @app.command("create")
30
+ def create_automation(
31
+ ctx: typer.Context,
32
+ name: str = typer.Option(..., "--name", "-n"),
33
+ trigger: str = typer.Option(..., "--trigger", help="e.g. schedule, github.pr_merged"),
34
+ action: Optional[str] = typer.Option(None, "--action"),
35
+ condition: Optional[str] = typer.Option(None, "--condition"),
36
+ cron: Optional[str] = typer.Option(None, "--cron", help="schedule_cron for schedule triggers"),
37
+ ):
38
+ """Create an automation."""
39
+ with errors():
40
+ show(ctx, client(ctx).automations.create(
41
+ name, trigger, action=action, condition=condition, schedule_cron=cron))
42
+
43
+
44
+ @app.command("toggle")
45
+ def toggle(ctx: typer.Context, automation_id: str = typer.Argument(...)):
46
+ """Enable/disable an automation."""
47
+ with errors():
48
+ show(ctx, client(ctx).automations.toggle(automation_id))
49
+
50
+
51
+ @app.command("execute")
52
+ def execute(ctx: typer.Context, automation_id: str = typer.Argument(...)):
53
+ """Run an automation now (writes a real run record)."""
54
+ with errors():
55
+ show(ctx, client(ctx).automations.execute(automation_id))
56
+
57
+
58
+ @app.command("history")
59
+ def history(ctx: typer.Context, automation_id: str = typer.Argument(...)):
60
+ """List run records for an automation."""
61
+ with errors():
62
+ show(ctx, client(ctx).automations.runs(automation_id),
63
+ columns=["run_id", "status", "trigger_type", "started_at"])
64
+
65
+
66
+ @app.command("run-status")
67
+ def run_status(
68
+ ctx: typer.Context,
69
+ automation_id: str = typer.Argument(...),
70
+ run_id: str = typer.Argument(...),
71
+ ):
72
+ """Show one run's detail."""
73
+ with errors():
74
+ show(ctx, client(ctx).automations.run_status(automation_id, run_id))
75
+
76
+
77
+ @app.command("export")
78
+ def export(ctx: typer.Context, automation_id: str = typer.Argument(...)):
79
+ """Export the automation definition as YAML (workflows-as-code)."""
80
+ with errors():
81
+ data = client(ctx).automations.export(automation_id)
82
+ if out(ctx) == "table" and isinstance(data, dict) and data.get("yaml"):
83
+ typer.echo(data["yaml"])
84
+ else:
85
+ show(ctx, data)
86
+
87
+
88
+ @app.command("versions")
89
+ def versions(ctx: typer.Context, automation_id: str = typer.Argument(...)):
90
+ """List the automation's version history."""
91
+ with errors():
92
+ show(ctx, client(ctx).automations.versions(automation_id),
93
+ columns=["version", "note", "created_at"])
94
+
95
+
96
+ @app.command("rollback")
97
+ def rollback(
98
+ ctx: typer.Context,
99
+ automation_id: str = typer.Argument(...),
100
+ to: int = typer.Option(..., "--to", help="Version number to roll back to"),
101
+ ):
102
+ """Roll an automation back to a prior version."""
103
+ with errors():
104
+ show(ctx, client(ctx).automations.rollback(automation_id, to))
105
+
106
+
107
+ @app.command("templates")
108
+ def templates(ctx: typer.Context):
109
+ """List the dev automation template gallery."""
110
+ with errors():
111
+ show(ctx, client(ctx).automations.templates(),
112
+ columns=["template_id", "name", "trigger_type", "requires"])
@@ -0,0 +1,33 @@
1
+ """`lithora github` — GitHub integration status / repos / connect."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Optional
6
+
7
+ import typer
8
+
9
+ from ._common import client, errors, show
10
+
11
+ app = typer.Typer(no_args_is_help=True, help="GitHub integration.")
12
+
13
+
14
+ @app.command("status")
15
+ def status(ctx: typer.Context):
16
+ """Show GitHub connection status."""
17
+ with errors():
18
+ show(ctx, client(ctx).github.status())
19
+
20
+
21
+ @app.command("repos")
22
+ def repos(ctx: typer.Context, org: Optional[str] = typer.Option(None, "--org", "-o")):
23
+ """List accessible repositories."""
24
+ with errors():
25
+ show(ctx, client(ctx).github.repos(org=org),
26
+ columns=["full_name", "private", "default_branch"])
27
+
28
+
29
+ @app.command("connect")
30
+ def connect(ctx: typer.Context):
31
+ """Print the URL to connect GitHub in your browser (OAuth)."""
32
+ url = client(ctx).github.connect_url()
33
+ typer.echo("Open this URL to connect GitHub:\n {}".format(url))
@@ -0,0 +1,68 @@
1
+ """`lithora projects` — manage projects (repositories)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Optional
6
+
7
+ import typer
8
+
9
+ from ._common import client, errors, show
10
+
11
+ app = typer.Typer(no_args_is_help=True, help="Manage projects.")
12
+
13
+
14
+ @app.command("list")
15
+ def list_projects(ctx: typer.Context):
16
+ """List projects you can access."""
17
+ with errors():
18
+ show(ctx, client(ctx).projects.list(),
19
+ columns=["project_id", "name", "status", "team_id"])
20
+
21
+
22
+ @app.command("show")
23
+ def show_project(ctx: typer.Context, project_id: str = typer.Argument(...)):
24
+ """Show one project (with task stats)."""
25
+ with errors():
26
+ show(ctx, client(ctx).projects.get(project_id))
27
+
28
+
29
+ @app.command("create")
30
+ def create_project(
31
+ ctx: typer.Context,
32
+ name: str = typer.Option(..., "--name", "-n"),
33
+ team: str = typer.Option(..., "--team", "-t", help="Owning team id"),
34
+ description: Optional[str] = typer.Option(None, "--description"),
35
+ status: Optional[str] = typer.Option(None, "--status", help="active|archived|completed"),
36
+ ):
37
+ """Create a project."""
38
+ with errors():
39
+ show(ctx, client(ctx).projects.create(name, team, description=description, status=status))
40
+
41
+
42
+ @app.command("update")
43
+ def update_project(
44
+ ctx: typer.Context,
45
+ project_id: str = typer.Argument(...),
46
+ name: Optional[str] = typer.Option(None, "--name"),
47
+ status: Optional[str] = typer.Option(None, "--status"),
48
+ description: Optional[str] = typer.Option(None, "--description"),
49
+ ):
50
+ """Update a project's metadata."""
51
+ updates = {k: v for k, v in {"name": name, "status": status, "description": description}.items() if v is not None}
52
+ if not updates:
53
+ raise typer.BadParameter("Pass at least one of --name/--status/--description")
54
+ with errors():
55
+ show(ctx, client(ctx).projects.patch(project_id, **updates))
56
+
57
+
58
+ @app.command("delete")
59
+ def delete_project(
60
+ ctx: typer.Context,
61
+ project_id: str = typer.Argument(...),
62
+ yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation"),
63
+ ):
64
+ """Delete a project."""
65
+ if not yes and not typer.confirm("Delete project {}?".format(project_id)):
66
+ raise typer.Exit(0)
67
+ with errors():
68
+ show(ctx, client(ctx).projects.delete(project_id))
@@ -0,0 +1,30 @@
1
+ """`lithora search` — permission-scoped global search."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Optional
6
+
7
+ import typer
8
+
9
+ from ._common import client, errors, show
10
+
11
+ app = typer.Typer(no_args_is_help=True, help="Search across your workspace.")
12
+
13
+
14
+ @app.command("query")
15
+ def query(
16
+ ctx: typer.Context,
17
+ q: str = typer.Argument(..., help="Search query"),
18
+ limit: Optional[int] = typer.Option(None, "--limit", "-l"),
19
+ ):
20
+ """Search tasks, projects, docs and more."""
21
+ with errors():
22
+ show(ctx, client(ctx).search.query(q, limit=limit),
23
+ columns=["type", "title", "id"])
24
+
25
+
26
+ @app.command("recent")
27
+ def recent(ctx: typer.Context):
28
+ """Your recent searches."""
29
+ with errors():
30
+ show(ctx, client(ctx).search.recent())
@@ -0,0 +1,120 @@
1
+ """`lithora tasks` — manage issues/tasks."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import List, Optional
6
+
7
+ import typer
8
+
9
+ from ._common import client, errors, show
10
+
11
+ app = typer.Typer(no_args_is_help=True, help="Manage issues / tasks.")
12
+
13
+ _TASK_COLS = ["task_id", "title", "status", "priority", "assigned_to"]
14
+
15
+
16
+ @app.command("list")
17
+ def list_tasks(
18
+ ctx: typer.Context,
19
+ project: str = typer.Option(..., "--project", "-p", help="Project id"),
20
+ status: Optional[str] = typer.Option(None, "--status", "-s"),
21
+ ):
22
+ """List tasks in a project (optionally filtered by status)."""
23
+ with errors():
24
+ data = client(ctx).tasks.list(project)
25
+ if status:
26
+ rows = data.get("tasks", data) if isinstance(data, dict) else data
27
+ rows = [t for t in (rows or []) if t.get("status") == status]
28
+ data = {"tasks": rows}
29
+ show(ctx, data, columns=_TASK_COLS)
30
+
31
+
32
+ @app.command("my")
33
+ def my_tasks(ctx: typer.Context, status: Optional[str] = typer.Option(None, "--status", "-s")):
34
+ """List tasks assigned to you."""
35
+ with errors():
36
+ data = client(ctx).tasks.my()
37
+ if status:
38
+ rows = data.get("tasks", data) if isinstance(data, dict) else data
39
+ data = {"tasks": [t for t in (rows or []) if t.get("status") == status]}
40
+ show(ctx, data, columns=_TASK_COLS + ["project_id"])
41
+
42
+
43
+ @app.command("show")
44
+ def show_task(ctx: typer.Context, task_id: str = typer.Argument(...)):
45
+ """Show one task."""
46
+ with errors():
47
+ show(ctx, client(ctx).tasks.get(task_id))
48
+
49
+
50
+ @app.command("create")
51
+ def create_task(
52
+ ctx: typer.Context,
53
+ title: str = typer.Option(..., "--title", "-t"),
54
+ project: str = typer.Option(..., "--project", "-p"),
55
+ description: Optional[str] = typer.Option(None, "--description", "-d"),
56
+ priority: Optional[str] = typer.Option(None, "--priority", help="low|medium|high|urgent"),
57
+ assignee: Optional[str] = typer.Option(None, "--assignee", "-a", help="User id"),
58
+ due: Optional[str] = typer.Option(None, "--due", help="ISO date"),
59
+ tags: Optional[List[str]] = typer.Option(None, "--tag", help="Repeatable"),
60
+ ):
61
+ """Create a task."""
62
+ with errors():
63
+ show(ctx, client(ctx).tasks.create(
64
+ title, project, description=description, priority=priority,
65
+ assigned_to=assignee, due_date=due, tags=tags or None,
66
+ ))
67
+
68
+
69
+ @app.command("status")
70
+ def set_status(
71
+ ctx: typer.Context,
72
+ task_id: str = typer.Argument(...),
73
+ status: str = typer.Argument(..., help="todo|in_progress|in_review|done|blocked"),
74
+ ):
75
+ """Change a task's status."""
76
+ with errors():
77
+ show(ctx, client(ctx).tasks.set_status(task_id, status))
78
+
79
+
80
+ @app.command("update")
81
+ def update_task(
82
+ ctx: typer.Context,
83
+ task_id: str = typer.Argument(...),
84
+ title: Optional[str] = typer.Option(None, "--title"),
85
+ priority: Optional[str] = typer.Option(None, "--priority"),
86
+ description: Optional[str] = typer.Option(None, "--description"),
87
+ due: Optional[str] = typer.Option(None, "--due"),
88
+ ):
89
+ """Update task metadata."""
90
+ updates = {k: v for k, v in {
91
+ "title": title, "priority": priority, "description": description, "due_date": due,
92
+ }.items() if v is not None}
93
+ if not updates:
94
+ raise typer.BadParameter("Pass at least one field to update")
95
+ with errors():
96
+ show(ctx, client(ctx).tasks.update(task_id, **updates))
97
+
98
+
99
+ @app.command("assign")
100
+ def assign_task(
101
+ ctx: typer.Context,
102
+ task_id: str = typer.Argument(...),
103
+ user: str = typer.Option(..., "--user", "-u", help="Assignee user id"),
104
+ ):
105
+ """Assign a task (via task update)."""
106
+ with errors():
107
+ show(ctx, client(ctx).tasks.update(task_id, assigned_to=user))
108
+
109
+
110
+ @app.command("delete")
111
+ def delete_task(
112
+ ctx: typer.Context,
113
+ task_id: str = typer.Argument(...),
114
+ yes: bool = typer.Option(False, "--yes", "-y"),
115
+ ):
116
+ """Delete a task."""
117
+ if not yes and not typer.confirm("Delete task {}?".format(task_id)):
118
+ raise typer.Exit(0)
119
+ with errors():
120
+ show(ctx, client(ctx).tasks.delete(task_id))