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.
- lithora_cli/__init__.py +3 -0
- lithora_cli/commands/__init__.py +0 -0
- lithora_cli/commands/_common.py +41 -0
- lithora_cli/commands/account.py +70 -0
- lithora_cli/commands/ai.py +192 -0
- lithora_cli/commands/automations.py +112 -0
- lithora_cli/commands/github.py +33 -0
- lithora_cli/commands/projects.py +68 -0
- lithora_cli/commands/search.py +30 -0
- lithora_cli/commands/tasks.py +120 -0
- lithora_cli/commands/teams.py +43 -0
- lithora_cli/commands/work_items.py +64 -0
- lithora_cli/config.py +178 -0
- lithora_cli/main.py +164 -0
- lithora_cli/output.py +141 -0
- lithora_cli-0.2.0.dist-info/METADATA +162 -0
- lithora_cli-0.2.0.dist-info/RECORD +21 -0
- lithora_cli-0.2.0.dist-info/WHEEL +5 -0
- lithora_cli-0.2.0.dist-info/entry_points.txt +2 -0
- lithora_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
- lithora_cli-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""`lithora teams` — manage teams."""
|
|
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 teams.")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@app.command("list")
|
|
15
|
+
def list_teams(ctx: typer.Context):
|
|
16
|
+
"""List teams you belong to."""
|
|
17
|
+
with errors():
|
|
18
|
+
show(ctx, client(ctx).teams.list(), columns=["team_id", "name", "member_ids"])
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@app.command("show")
|
|
22
|
+
def show_team(ctx: typer.Context, team_id: str = typer.Argument(..., help="Team id")):
|
|
23
|
+
"""Show one team."""
|
|
24
|
+
with errors():
|
|
25
|
+
show(ctx, client(ctx).teams.get(team_id))
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@app.command("members")
|
|
29
|
+
def members(ctx: typer.Context, team_id: str = typer.Argument(...)):
|
|
30
|
+
"""List a team's members."""
|
|
31
|
+
with errors():
|
|
32
|
+
show(ctx, client(ctx).teams.members(team_id), columns=["user_id", "name", "role"])
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@app.command("create")
|
|
36
|
+
def create_team(
|
|
37
|
+
ctx: typer.Context,
|
|
38
|
+
name: str = typer.Option(..., "--name", "-n", help="Team name"),
|
|
39
|
+
description: Optional[str] = typer.Option(None, "--description"),
|
|
40
|
+
):
|
|
41
|
+
"""Create a team."""
|
|
42
|
+
with errors():
|
|
43
|
+
show(ctx, client(ctx).teams.create(name, description=description))
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""`lithora work-items` — the unified work graph: items, graph, cycle-time, PR status."""
|
|
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
|
+
from ..output import render
|
|
11
|
+
|
|
12
|
+
app = typer.Typer(no_args_is_help=True, help="The unified work graph.")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@app.command("list")
|
|
16
|
+
def list_items(ctx: typer.Context):
|
|
17
|
+
"""List work items."""
|
|
18
|
+
with errors():
|
|
19
|
+
show(ctx, client(ctx).work_items.list(), columns=["work_item_id", "type", "title"])
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@app.command("graph")
|
|
23
|
+
def graph(
|
|
24
|
+
ctx: typer.Context,
|
|
25
|
+
team: Optional[str] = typer.Option(None, "--team", "-t"),
|
|
26
|
+
project: Optional[str] = typer.Option(None, "--project", "-p"),
|
|
27
|
+
):
|
|
28
|
+
"""Show the work-graph nodes + edges (issues/PRs/docs/people)."""
|
|
29
|
+
if not team and not project:
|
|
30
|
+
raise typer.BadParameter("Pass --team or --project")
|
|
31
|
+
with errors():
|
|
32
|
+
data = client(ctx).work_items.graph(team_id=team, project_id=project)
|
|
33
|
+
if out(ctx) == "table":
|
|
34
|
+
typer.echo("Nodes: {} Edges: {}".format(data.get("node_count", 0), data.get("edge_count", 0)))
|
|
35
|
+
render({"nodes": data.get("nodes", [])}, "table", columns=["kind", "label", "id"])
|
|
36
|
+
else:
|
|
37
|
+
show(ctx, data)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@app.command("cycle-time")
|
|
41
|
+
def cycle_time(
|
|
42
|
+
ctx: typer.Context,
|
|
43
|
+
team: Optional[str] = typer.Option(None, "--team", "-t"),
|
|
44
|
+
project: Optional[str] = typer.Option(None, "--project", "-p"),
|
|
45
|
+
):
|
|
46
|
+
"""Issue→PR-merge cycle-time analytics (Preview metric)."""
|
|
47
|
+
if not team and not project:
|
|
48
|
+
raise typer.BadParameter("Pass --team or --project")
|
|
49
|
+
with errors():
|
|
50
|
+
show(ctx, client(ctx).work_items.cycle_time(team_id=team, project_id=project))
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@app.command("pr-status")
|
|
54
|
+
def pr_status(ctx: typer.Context, task_id: str = typer.Argument(...)):
|
|
55
|
+
"""Live GitHub PR + CI status for a task."""
|
|
56
|
+
with errors():
|
|
57
|
+
show(ctx, client(ctx).work_items.pr_status(task_id))
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@app.command("resolve")
|
|
61
|
+
def resolve(ctx: typer.Context, ref: str = typer.Option(..., "--ref", help="e.g. 412")):
|
|
62
|
+
"""Resolve a #N PR/issue reference to its work item."""
|
|
63
|
+
with errors():
|
|
64
|
+
show(ctx, client(ctx).work_items.resolve_ref(ref.lstrip("#")))
|
lithora_cli/config.py
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""CLI configuration: multi-profile contexts + secure token storage.
|
|
2
|
+
|
|
3
|
+
Profiles (kubectl/aws-style) let you target multiple accounts/orgs. Non-secret
|
|
4
|
+
state lives in ``~/.lithora/config.json`` (dir 700 / file 600); the bearer token
|
|
5
|
+
is stored in the OS keychain via ``keyring`` when available (macOS Keychain,
|
|
6
|
+
libsecret, Windows Credential Manager), falling back to the 600-mode file.
|
|
7
|
+
|
|
8
|
+
Precedence (highest first): CLI flag > env var > profile on disk > default.
|
|
9
|
+
Env: ``LITHORA_TOKEN``, ``LITHORA_BASE_URL``, ``LITHORA_PROFILE``.
|
|
10
|
+
|
|
11
|
+
Backward compatible with the v0.1 single-file shape ``{"base_url","token"}``,
|
|
12
|
+
which is migrated into ``profiles.default`` on first write.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import os
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Dict, Optional
|
|
22
|
+
|
|
23
|
+
DEFAULT_BASE_URL = "https://api.lithora.io"
|
|
24
|
+
CONFIG_DIR = Path.home() / ".lithora"
|
|
25
|
+
CONFIG_PATH = CONFIG_DIR / "config.json"
|
|
26
|
+
_KEYRING_SERVICE = "lithora-cli"
|
|
27
|
+
|
|
28
|
+
try: # keyring is optional — degrade gracefully to file storage.
|
|
29
|
+
import keyring as _keyring
|
|
30
|
+
from keyring.errors import KeyringError as _KeyringError
|
|
31
|
+
except Exception: # pragma: no cover - import guard
|
|
32
|
+
_keyring = None
|
|
33
|
+
|
|
34
|
+
class _KeyringError(Exception):
|
|
35
|
+
pass
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class ResolvedConfig:
|
|
40
|
+
"""The effective config for one command invocation."""
|
|
41
|
+
profile: str
|
|
42
|
+
base_url: str
|
|
43
|
+
token: Optional[str]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _read() -> dict:
|
|
47
|
+
try:
|
|
48
|
+
with CONFIG_PATH.open("r", encoding="utf-8") as fh:
|
|
49
|
+
data = json.load(fh)
|
|
50
|
+
return data if isinstance(data, dict) else {}
|
|
51
|
+
except (FileNotFoundError, ValueError, OSError):
|
|
52
|
+
return {}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _migrate(data: dict) -> dict:
|
|
56
|
+
"""Upgrade a v0.1 flat file ({base_url, token}) to the profiles shape."""
|
|
57
|
+
if "profiles" in data:
|
|
58
|
+
return data
|
|
59
|
+
profiles: Dict[str, dict] = {}
|
|
60
|
+
if data.get("base_url") or data.get("token"):
|
|
61
|
+
profiles["default"] = {"base_url": data.get("base_url", DEFAULT_BASE_URL)}
|
|
62
|
+
if data.get("token"):
|
|
63
|
+
profiles["default"]["token"] = data["token"] # keep where it was
|
|
64
|
+
return {"current_profile": "default", "profiles": profiles}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _write(data: dict) -> None:
|
|
68
|
+
CONFIG_DIR.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
69
|
+
try:
|
|
70
|
+
os.chmod(CONFIG_DIR, 0o700)
|
|
71
|
+
except OSError:
|
|
72
|
+
pass
|
|
73
|
+
fd = os.open(str(CONFIG_PATH), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
74
|
+
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
|
75
|
+
json.dump(data, fh, indent=2)
|
|
76
|
+
fh.write("\n")
|
|
77
|
+
try:
|
|
78
|
+
os.chmod(CONFIG_PATH, 0o600)
|
|
79
|
+
except OSError:
|
|
80
|
+
pass
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
# ---- token storage (keychain primary, file fallback) ----------------------
|
|
84
|
+
|
|
85
|
+
def _keyring_account(profile: str, base_url: str) -> str:
|
|
86
|
+
return "{}@{}".format(profile, base_url)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _store_token(profile: str, base_url: str, token: str) -> bool:
|
|
90
|
+
"""Try the OS keychain; return True if stored there (so we DON'T write it to file).
|
|
91
|
+
|
|
92
|
+
Any keyring failure (locked/absent/null backend) degrades to the file store.
|
|
93
|
+
"""
|
|
94
|
+
if _keyring is not None:
|
|
95
|
+
try:
|
|
96
|
+
_keyring.set_password(_KEYRING_SERVICE, _keyring_account(profile, base_url), token)
|
|
97
|
+
return True
|
|
98
|
+
except Exception:
|
|
99
|
+
pass
|
|
100
|
+
return False
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _load_token(profile: str, base_url: str, prof: dict) -> Optional[str]:
|
|
104
|
+
if _keyring is not None:
|
|
105
|
+
try:
|
|
106
|
+
tok = _keyring.get_password(_KEYRING_SERVICE, _keyring_account(profile, base_url))
|
|
107
|
+
if tok:
|
|
108
|
+
return tok
|
|
109
|
+
except Exception:
|
|
110
|
+
pass
|
|
111
|
+
return prof.get("token") # file fallback
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
# ---- public API -----------------------------------------------------------
|
|
115
|
+
|
|
116
|
+
def current_profile() -> str:
|
|
117
|
+
return os.environ.get("LITHORA_PROFILE") or _migrate(_read()).get("current_profile", "default")
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def list_profiles() -> Dict[str, dict]:
|
|
121
|
+
return _migrate(_read()).get("profiles", {})
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def resolve(profile: Optional[str] = None, base_url: Optional[str] = None,
|
|
125
|
+
token: Optional[str] = None) -> ResolvedConfig:
|
|
126
|
+
"""Compute the effective config (flag > env > file > default)."""
|
|
127
|
+
data = _migrate(_read())
|
|
128
|
+
prof_name = profile or os.environ.get("LITHORA_PROFILE") or data.get("current_profile", "default")
|
|
129
|
+
prof = data.get("profiles", {}).get(prof_name, {})
|
|
130
|
+
|
|
131
|
+
eff_base = (base_url or os.environ.get("LITHORA_BASE_URL")
|
|
132
|
+
or prof.get("base_url") or DEFAULT_BASE_URL).rstrip("/")
|
|
133
|
+
eff_token = token or os.environ.get("LITHORA_TOKEN") or _load_token(prof_name, eff_base, prof)
|
|
134
|
+
return ResolvedConfig(profile=prof_name, base_url=eff_base, token=eff_token)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def save_login(profile: str, base_url: str, token: str) -> str:
|
|
138
|
+
"""Persist a successful login. Returns where the token was stored ('keychain'|'file')."""
|
|
139
|
+
data = _migrate(_read())
|
|
140
|
+
data.setdefault("profiles", {})
|
|
141
|
+
prof = data["profiles"].setdefault(profile, {})
|
|
142
|
+
prof["base_url"] = base_url.rstrip("/")
|
|
143
|
+
prof.pop("token", None)
|
|
144
|
+
data.setdefault("current_profile", profile)
|
|
145
|
+
|
|
146
|
+
in_keychain = _store_token(profile, base_url, token)
|
|
147
|
+
if not in_keychain:
|
|
148
|
+
prof["token"] = token # 600-mode file fallback
|
|
149
|
+
_write(data)
|
|
150
|
+
return "keychain" if in_keychain else "file"
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def set_base_url(profile: str, base_url: str) -> None:
|
|
154
|
+
data = _migrate(_read())
|
|
155
|
+
data.setdefault("profiles", {}).setdefault(profile, {})["base_url"] = base_url.rstrip("/")
|
|
156
|
+
_write(data)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def use_profile(profile: str) -> None:
|
|
160
|
+
data = _migrate(_read())
|
|
161
|
+
data["current_profile"] = profile
|
|
162
|
+
data.setdefault("profiles", {}).setdefault(profile, {"base_url": DEFAULT_BASE_URL})
|
|
163
|
+
_write(data)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def clear_token(profile: str) -> None:
|
|
167
|
+
"""Logout: remove the token from keychain and file for one profile."""
|
|
168
|
+
data = _migrate(_read())
|
|
169
|
+
prof = data.get("profiles", {}).get(profile, {})
|
|
170
|
+
base_url = prof.get("base_url", DEFAULT_BASE_URL)
|
|
171
|
+
if _keyring is not None:
|
|
172
|
+
try:
|
|
173
|
+
_keyring.delete_password(_KEYRING_SERVICE, _keyring_account(profile, base_url))
|
|
174
|
+
except Exception:
|
|
175
|
+
pass
|
|
176
|
+
if "token" in prof:
|
|
177
|
+
prof.pop("token", None)
|
|
178
|
+
_write(data)
|
lithora_cli/main.py
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""Lithora CLI — the terminal-native control plane for Lithora.
|
|
2
|
+
|
|
3
|
+
Built on the shared `lithora` Python SDK (no vendored HTTP client). Typer app
|
|
4
|
+
with multi-profile config, secure token storage, a stable --json contract, and
|
|
5
|
+
the confirmation-gated agent (`lithora ai`).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import sys
|
|
11
|
+
from typing import Optional
|
|
12
|
+
|
|
13
|
+
import typer
|
|
14
|
+
|
|
15
|
+
from lithora import AuthChallengeError, AuthError, Lithora, LithoraError
|
|
16
|
+
|
|
17
|
+
from . import __version__, config
|
|
18
|
+
from .commands import (
|
|
19
|
+
account, automations, github, projects, search, tasks, teams, work_items, ai,
|
|
20
|
+
)
|
|
21
|
+
from .output import error, exit_code_for, is_tty, success
|
|
22
|
+
|
|
23
|
+
app = typer.Typer(
|
|
24
|
+
no_args_is_help=True,
|
|
25
|
+
add_completion=True,
|
|
26
|
+
help="Lithora — manage teams, projects, issues, automations and the AI agent from your terminal.",
|
|
27
|
+
rich_markup_mode="rich",
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _version_cb(value: bool):
|
|
32
|
+
if value:
|
|
33
|
+
typer.echo("lithora {}".format(__version__))
|
|
34
|
+
raise typer.Exit(0)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@app.callback()
|
|
38
|
+
def main_callback(
|
|
39
|
+
ctx: typer.Context,
|
|
40
|
+
profile: Optional[str] = typer.Option(None, "--profile", envvar="LITHORA_PROFILE", help="Config profile/context."),
|
|
41
|
+
base_url: Optional[str] = typer.Option(None, "--base-url", envvar="LITHORA_BASE_URL", help="API root."),
|
|
42
|
+
output: str = typer.Option("table", "--output", "-o", envvar="LITHORA_OUTPUT", help="table | json | yaml"),
|
|
43
|
+
token: Optional[str] = typer.Option(None, "--token", envvar="LITHORA_TOKEN", help="Bearer token override.", show_default=False),
|
|
44
|
+
debug: bool = typer.Option(False, "--debug", help="Verbose, redacted request logging."),
|
|
45
|
+
_version: bool = typer.Option(None, "--version", callback=_version_cb, is_eager=True, help="Show version and exit."),
|
|
46
|
+
):
|
|
47
|
+
"""Resolve config and build the API client for the subcommand."""
|
|
48
|
+
if output not in ("table", "json", "yaml"):
|
|
49
|
+
raise typer.BadParameter("output must be one of: table, json, yaml")
|
|
50
|
+
cfg = config.resolve(profile=profile, base_url=base_url, token=token)
|
|
51
|
+
|
|
52
|
+
# Security: warn loudly when pointed at a non-HTTPS / non-Lithora host.
|
|
53
|
+
if cfg.base_url.startswith("http://") and "localhost" not in cfg.base_url and "127.0.0.1" not in cfg.base_url:
|
|
54
|
+
error("WARNING: insecure base URL ({}). Tokens will be sent over plaintext.".format(cfg.base_url))
|
|
55
|
+
|
|
56
|
+
ctx.obj = {
|
|
57
|
+
"client": Lithora(base_url=cfg.base_url, token=cfg.token, timeout=60),
|
|
58
|
+
"config": cfg,
|
|
59
|
+
"output": output,
|
|
60
|
+
"debug": debug,
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# ---- top-level auth commands ----------------------------------------------
|
|
65
|
+
|
|
66
|
+
@app.command()
|
|
67
|
+
def login(
|
|
68
|
+
ctx: typer.Context,
|
|
69
|
+
email: Optional[str] = typer.Option(None, "--email", "-e", help="Account email (prompted if omitted)."),
|
|
70
|
+
password_stdin: bool = typer.Option(False, "--password-stdin", help="Read the password from stdin (CI)."),
|
|
71
|
+
):
|
|
72
|
+
"""Authenticate and store a token securely (keychain, else 600-mode file).
|
|
73
|
+
|
|
74
|
+
The password is never accepted as a flag value (shell history / argv leak).
|
|
75
|
+
Use the interactive prompt, or pipe it with --password-stdin in CI.
|
|
76
|
+
"""
|
|
77
|
+
cfg = ctx.obj["config"]
|
|
78
|
+
email = email or typer.prompt("Email")
|
|
79
|
+
if password_stdin or not is_tty():
|
|
80
|
+
pw = sys.stdin.readline().rstrip("\n")
|
|
81
|
+
else:
|
|
82
|
+
pw = typer.prompt("Password", hide_input=True)
|
|
83
|
+
|
|
84
|
+
client = Lithora(base_url=cfg.base_url)
|
|
85
|
+
try:
|
|
86
|
+
user = client.login(email, pw)
|
|
87
|
+
except AuthChallengeError as e:
|
|
88
|
+
error(str(e))
|
|
89
|
+
error("2FA/OTP login isn't supported headlessly yet — use --token, or a PAT once available.")
|
|
90
|
+
raise typer.Exit(exit_code_for(401))
|
|
91
|
+
except AuthError as e:
|
|
92
|
+
error(str(getattr(e, "detail", e)) + " (check email/password)")
|
|
93
|
+
raise typer.Exit(exit_code_for(401))
|
|
94
|
+
except LithoraError as e:
|
|
95
|
+
error(str(e))
|
|
96
|
+
raise typer.Exit(1)
|
|
97
|
+
|
|
98
|
+
where = config.save_login(cfg.profile, cfg.base_url, client.token)
|
|
99
|
+
success("Logged in as {} (profile '{}', token in {}).".format(
|
|
100
|
+
user.get("email") or user.get("name") or email, cfg.profile, where))
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@app.command()
|
|
104
|
+
def logout(ctx: typer.Context):
|
|
105
|
+
"""Remove the stored token for the current profile."""
|
|
106
|
+
cfg = ctx.obj["config"]
|
|
107
|
+
config.clear_token(cfg.profile)
|
|
108
|
+
success("Logged out of profile '{}'.".format(cfg.profile))
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@app.command()
|
|
112
|
+
def whoami(ctx: typer.Context):
|
|
113
|
+
"""Show the currently authenticated user."""
|
|
114
|
+
from .commands._common import errors, show
|
|
115
|
+
with errors():
|
|
116
|
+
show(ctx, ctx.obj["client"].auth.me(),
|
|
117
|
+
columns=["user_id", "name", "email", "role"])
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@app.command()
|
|
121
|
+
def doctor(ctx: typer.Context):
|
|
122
|
+
"""Diagnose auth, connectivity and config."""
|
|
123
|
+
cfg = ctx.obj["config"]
|
|
124
|
+
typer.echo("lithora {}".format(__version__))
|
|
125
|
+
typer.echo("profile : {}".format(cfg.profile))
|
|
126
|
+
typer.echo("base url : {}".format(cfg.base_url))
|
|
127
|
+
typer.echo("token : {}".format("present" if cfg.token else "MISSING (run 'lithora login')"))
|
|
128
|
+
if not cfg.token:
|
|
129
|
+
raise typer.Exit(0)
|
|
130
|
+
try:
|
|
131
|
+
me = ctx.obj["client"].auth.me()
|
|
132
|
+
typer.secho("auth : OK ({})".format(me.get("email") or me.get("user_id")), fg=typer.colors.GREEN)
|
|
133
|
+
except AuthError:
|
|
134
|
+
typer.secho("auth : FAILED — token rejected (re-run 'lithora login')", fg=typer.colors.RED)
|
|
135
|
+
raise typer.Exit(exit_code_for(401))
|
|
136
|
+
except LithoraError as e:
|
|
137
|
+
typer.secho("connectivity: FAILED — {}".format(e), fg=typer.colors.RED)
|
|
138
|
+
raise typer.Exit(1)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
# ---- command groups --------------------------------------------------------
|
|
142
|
+
app.add_typer(teams.app, name="teams")
|
|
143
|
+
app.add_typer(projects.app, name="projects")
|
|
144
|
+
app.add_typer(tasks.app, name="tasks")
|
|
145
|
+
app.add_typer(work_items.app, name="work-items")
|
|
146
|
+
app.add_typer(automations.app, name="automations")
|
|
147
|
+
app.add_typer(github.app, name="github")
|
|
148
|
+
app.add_typer(search.app, name="search")
|
|
149
|
+
app.add_typer(ai.app, name="ai")
|
|
150
|
+
app.add_typer(account.token_app, name="token")
|
|
151
|
+
app.add_typer(account.profile_app, name="profile")
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def main():
|
|
155
|
+
"""Console-script entry point with top-level signal handling."""
|
|
156
|
+
try:
|
|
157
|
+
app()
|
|
158
|
+
except KeyboardInterrupt:
|
|
159
|
+
error("Interrupted.")
|
|
160
|
+
raise SystemExit(130)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
if __name__ == "__main__":
|
|
164
|
+
main()
|
lithora_cli/output.py
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""Output contract: JSON/table formatting, TTY detection, exit codes, error mapping.
|
|
2
|
+
|
|
3
|
+
Two output modes drive everything:
|
|
4
|
+
* ``json`` — stable, machine-parseable (the exact API payload); for scripting/CI.
|
|
5
|
+
* ``table`` — human, TTY-aware, colorized (rich); the default on a terminal.
|
|
6
|
+
|
|
7
|
+
Exit codes follow Unix conventions so CI can branch on them.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import sys
|
|
14
|
+
from typing import Any, Dict, List, Optional, Sequence
|
|
15
|
+
|
|
16
|
+
try:
|
|
17
|
+
from rich.console import Console
|
|
18
|
+
from rich.table import Table
|
|
19
|
+
_console = Console()
|
|
20
|
+
_err_console = Console(stderr=True)
|
|
21
|
+
_HAVE_RICH = True
|
|
22
|
+
except Exception: # pragma: no cover
|
|
23
|
+
_console = None
|
|
24
|
+
_err_console = None
|
|
25
|
+
_HAVE_RICH = False
|
|
26
|
+
|
|
27
|
+
# ---- exit codes -----------------------------------------------------------
|
|
28
|
+
EXIT_OK = 0
|
|
29
|
+
EXIT_ERROR = 1 # generic failure
|
|
30
|
+
EXIT_USAGE = 2 # bad usage / confirmation needed but no TTY/--yes
|
|
31
|
+
EXIT_AUTH = 3 # 401 / not logged in
|
|
32
|
+
EXIT_NOTFOUND = 4 # 404
|
|
33
|
+
EXIT_CONFLICT = 5 # 409
|
|
34
|
+
EXIT_VALIDATION = 22 # 400/422 invalid input
|
|
35
|
+
EXIT_INTERRUPT = 130 # Ctrl-C
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def is_tty() -> bool:
|
|
39
|
+
return sys.stdout.isatty()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def exit_code_for(status: Optional[int]) -> int:
|
|
43
|
+
return {
|
|
44
|
+
401: EXIT_AUTH, 403: EXIT_AUTH,
|
|
45
|
+
404: EXIT_NOTFOUND,
|
|
46
|
+
409: EXIT_CONFLICT,
|
|
47
|
+
400: EXIT_VALIDATION, 422: EXIT_VALIDATION,
|
|
48
|
+
}.get(status or 0, EXIT_ERROR)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def print_json(data: Any) -> None:
|
|
52
|
+
sys.stdout.write(json.dumps(data, indent=2, default=str) + "\n")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def error(message: str) -> None:
|
|
56
|
+
"""Write an error to stderr (red on a TTY)."""
|
|
57
|
+
if _HAVE_RICH and _err_console is not None:
|
|
58
|
+
_err_console.print("[bold red]Error:[/] {}".format(message))
|
|
59
|
+
else:
|
|
60
|
+
sys.stderr.write("Error: {}\n".format(message))
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def success(message: str) -> None:
|
|
64
|
+
if _HAVE_RICH and _console is not None and is_tty():
|
|
65
|
+
_console.print("[bold green]✓[/] {}".format(message))
|
|
66
|
+
else:
|
|
67
|
+
sys.stdout.write(message + "\n")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _coerce_rows(data: Any) -> List[Dict[str, Any]]:
|
|
71
|
+
if isinstance(data, dict):
|
|
72
|
+
# common envelopes: {teams:[...]}, {tasks:[...]}, {results:[...]}, {items:[...]}
|
|
73
|
+
for key in ("items", "results", "data", "teams", "projects", "tasks",
|
|
74
|
+
"automations", "sessions", "pending", "runs", "versions",
|
|
75
|
+
"work_items", "nodes", "repos", "repositories"):
|
|
76
|
+
if isinstance(data.get(key), list):
|
|
77
|
+
return data[key]
|
|
78
|
+
return [data]
|
|
79
|
+
if isinstance(data, list):
|
|
80
|
+
return [r if isinstance(r, dict) else {"value": r} for r in data]
|
|
81
|
+
return [{"value": data}]
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def render(data: Any, output: str = "table", *, columns: Optional[Sequence[str]] = None,
|
|
85
|
+
title: Optional[str] = None) -> None:
|
|
86
|
+
"""Render a payload in the requested mode. ``columns`` selects/orders table fields."""
|
|
87
|
+
if output == "json":
|
|
88
|
+
print_json(data)
|
|
89
|
+
return
|
|
90
|
+
if output == "yaml":
|
|
91
|
+
try:
|
|
92
|
+
import yaml # optional
|
|
93
|
+
sys.stdout.write(yaml.safe_dump(data, sort_keys=False))
|
|
94
|
+
return
|
|
95
|
+
except Exception:
|
|
96
|
+
print_json(data)
|
|
97
|
+
return
|
|
98
|
+
|
|
99
|
+
rows = _coerce_rows(data)
|
|
100
|
+
if not rows:
|
|
101
|
+
if is_tty():
|
|
102
|
+
(_console or sys).print("[dim]— no results —[/]") if _HAVE_RICH else print("— no results —")
|
|
103
|
+
return
|
|
104
|
+
|
|
105
|
+
cols = list(columns) if columns else _auto_columns(rows)
|
|
106
|
+
if _HAVE_RICH and _console is not None and is_tty():
|
|
107
|
+
table = Table(title=title, header_style="bold", expand=False)
|
|
108
|
+
for c in cols:
|
|
109
|
+
table.add_column(c)
|
|
110
|
+
for r in rows:
|
|
111
|
+
table.add_row(*[_fmt_cell(r.get(c)) for c in cols])
|
|
112
|
+
_console.print(table)
|
|
113
|
+
else: # non-TTY / no rich → tab-separated (pipe-friendly)
|
|
114
|
+
sys.stdout.write("\t".join(cols) + "\n")
|
|
115
|
+
for r in rows:
|
|
116
|
+
sys.stdout.write("\t".join(_fmt_cell(r.get(c)) for c in cols) + "\n")
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _auto_columns(rows: List[Dict[str, Any]]) -> List[str]:
|
|
120
|
+
preferred = ["id", "task_id", "project_id", "team_id", "automation_id", "session_id",
|
|
121
|
+
"name", "title", "status", "priority", "trigger_type", "is_active",
|
|
122
|
+
"created_at", "updated_at"]
|
|
123
|
+
keys: List[str] = []
|
|
124
|
+
seen = set(rows[0].keys())
|
|
125
|
+
for p in preferred:
|
|
126
|
+
if p in seen and p not in keys:
|
|
127
|
+
keys.append(p)
|
|
128
|
+
for k in rows[0].keys():
|
|
129
|
+
if k not in keys and not str(k).startswith("_") and len(keys) < 7:
|
|
130
|
+
keys.append(k)
|
|
131
|
+
return keys or list(rows[0].keys())[:7]
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _fmt_cell(v: Any) -> str:
|
|
135
|
+
if v is None:
|
|
136
|
+
return "-"
|
|
137
|
+
if isinstance(v, bool):
|
|
138
|
+
return "yes" if v else "no"
|
|
139
|
+
if isinstance(v, (list, dict)):
|
|
140
|
+
return json.dumps(v, default=str)[:60]
|
|
141
|
+
return str(v)
|