lql-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,92 @@
1
+ import sys
2
+ from typing import Annotated, Optional
3
+
4
+ import typer
5
+
6
+ from .._opts import ApiUrlOpt, JsonOpt, ProfileOpt
7
+ from ..api import ApiClient
8
+ from ..output import print_json, print_table
9
+ from ..sessions import resolve_session_id
10
+ from ..util import q
11
+
12
+ app = typer.Typer(help="Manage reports")
13
+
14
+ SessionOpt = Annotated[Optional[str], typer.Option("--session", help="Target a specific review session (advanced)")]
15
+
16
+
17
+ @app.command("list")
18
+ def list_reports(
19
+ dataset_id: Annotated[str, typer.Argument(help="Dataset ID")],
20
+ session: SessionOpt = None,
21
+ json_out: JsonOpt = False,
22
+ profile: ProfileOpt = None,
23
+ api_url: ApiUrlOpt = None,
24
+ ) -> None:
25
+ """List reports for a dataset."""
26
+ client = ApiClient(profile=profile, api_url=api_url)
27
+ session_id = resolve_session_id(client, dataset_id, session)
28
+ items = client.get(f"/v1/sessions/{q(session_id)}/reports").json()
29
+ print_table(
30
+ ["ID", "Title", "Rows", "Created"],
31
+ [
32
+ [
33
+ r.get("id") or "",
34
+ r.get("title") or "",
35
+ r.get("row_count") if r.get("row_count") is not None else "",
36
+ r.get("created_at") or "",
37
+ ]
38
+ for r in items
39
+ ],
40
+ json_out,
41
+ items,
42
+ )
43
+
44
+
45
+ @app.command("show")
46
+ def show(
47
+ report_id: Annotated[str, typer.Argument(help="Report ID")],
48
+ json_out: JsonOpt = False,
49
+ profile: ProfileOpt = None,
50
+ api_url: ApiUrlOpt = None,
51
+ ) -> None:
52
+ """Show a report."""
53
+ client = ApiClient(profile=profile, api_url=api_url)
54
+ r = client.get(f"/v1/reports/{q(report_id)}").json()
55
+ if json_out:
56
+ print_json(r)
57
+ else:
58
+ print_table(
59
+ ["Field", "Value"],
60
+ [
61
+ ["ID", r.get("id") or ""],
62
+ ["Title", r.get("title") or ""],
63
+ ["Rows", r.get("row_count") if r.get("row_count") is not None else ""],
64
+ ["Summary", r.get("analysis_summary") or r.get("summary") or ""],
65
+ ["Created", r.get("created_at") or ""],
66
+ ],
67
+ False,
68
+ [r],
69
+ )
70
+
71
+
72
+ @app.command("create")
73
+ def create(
74
+ dataset_id: Annotated[str, typer.Argument(help="Dataset ID")],
75
+ title: Annotated[str, typer.Option("--title", help="Report title")],
76
+ summary: Annotated[Optional[str], typer.Option("--summary", help="Manual summary")] = None,
77
+ session: SessionOpt = None,
78
+ json_out: JsonOpt = False,
79
+ profile: ProfileOpt = None,
80
+ api_url: ApiUrlOpt = None,
81
+ ) -> None:
82
+ """Publish a report for a dataset (bundles annotations + LLM analysis)."""
83
+ client = ApiClient(profile=profile, api_url=api_url)
84
+ session_id = resolve_session_id(client, dataset_id, session)
85
+ body: dict = {"title": title}
86
+ if summary:
87
+ body["summary"] = summary
88
+ data = client.post(f"/v1/sessions/{q(session_id)}/reports", json=body).json()
89
+ if json_out:
90
+ print_json(data)
91
+ else:
92
+ sys.stdout.write(f"Created report: {data.get('id', 'ok')}\n")
lql/commands/skills.py ADDED
@@ -0,0 +1,116 @@
1
+ import shutil
2
+ import sys
3
+ from pathlib import Path
4
+ from typing import Annotated, List, Optional
5
+
6
+ import typer
7
+
8
+ from .._opts import JsonOpt
9
+ from ..output import print_error, print_json
10
+
11
+ app = typer.Typer(help="Install the lql agent skill into Claude Code and Codex")
12
+
13
+ # Thin pointer skill. The body deliberately does NOT embed the full reference —
14
+ # it tells the agent to run `lql instructions`, which is always in sync with the
15
+ # installed binary.
16
+ SKILL_MD = """---
17
+ name: lql
18
+ description: Use the lql CLI to work with the Liquid DataViewer platform — spec docs, datasets, evals, annotations, highlights, issues, reports, workspaces, and S3/HuggingFace storage buckets. Use whenever the user wants to read or edit a spec doc, list/inspect/upload datasets, run or review evals, annotate dataset rows, or manage DataViewer workspaces from the command line.
19
+ ---
20
+
21
+ # lql — Liquid DataViewer CLI
22
+
23
+ `lql` is the command-line interface for the Liquid DataViewer platform. Use it
24
+ for spec docs, datasets, evals, annotations, highlights, issues, reports,
25
+ workspaces, and S3 / HuggingFace storage buckets.
26
+
27
+ **Before using lql, run `lql instructions`** to load the complete, up-to-date
28
+ command reference — every command, flag, exit code, and agentic workflow. It is
29
+ always in sync with the installed version, so prefer it over guessing flags.
30
+
31
+ Quick start:
32
+
33
+ lql whoami # confirm you're authenticated (run `lql login` if not)
34
+ lql instructions # full reference — read this first
35
+ lql workspaces list --json
36
+ lql datasets list --workspace <id> --json
37
+
38
+ All commands accept `--json` for stable, machine-readable output. Errors go to
39
+ stderr as `{ "error": "...", "code": "..." }`; data goes to stdout.
40
+ """
41
+
42
+
43
+ def _parse_tools(opt: Optional[str]) -> Optional[List[str]]:
44
+ value = (opt or "both").lower()
45
+ if value == "both":
46
+ return ["claude", "codex"]
47
+ if value in ("claude", "codex"):
48
+ return [value]
49
+ return None
50
+
51
+
52
+ def _resolve_targets(tools: List[str], project: bool):
53
+ base = Path.cwd() if project else Path.home()
54
+ roots = {
55
+ "claude": base / ".claude" / "skills" / "lql",
56
+ "codex": base / ".codex" / "skills" / "lql",
57
+ }
58
+ return [{"tool": t, "dir": roots[t], "file": roots[t] / "SKILL.md"} for t in tools]
59
+
60
+
61
+ @app.command("install")
62
+ def install(
63
+ tool: Annotated[str, typer.Option("--tool", help="Which agent to target: claude, codex, or both")] = "both",
64
+ project: Annotated[bool, typer.Option("--project", help="Install into the current directory instead of the home dir")] = False,
65
+ force: Annotated[bool, typer.Option("--force", help="Overwrite an existing skill file")] = False,
66
+ json_out: JsonOpt = False,
67
+ ) -> None:
68
+ """Install the lql skill so coding agents know how to use lql."""
69
+ tools = _parse_tools(tool)
70
+ if not tools:
71
+ print_error(f"Invalid --tool '{tool}'. Use claude, codex, or both.", "usage")
72
+ raise typer.Exit(1)
73
+ targets = _resolve_targets(tools, project)
74
+ results = []
75
+ for t in targets:
76
+ if t["file"].exists() and not force:
77
+ results.append({"tool": t["tool"], "path": str(t["file"]), "status": "exists"})
78
+ continue
79
+ t["dir"].mkdir(parents=True, exist_ok=True)
80
+ t["file"].write_text(SKILL_MD, "utf-8")
81
+ results.append({"tool": t["tool"], "path": str(t["file"]), "status": "installed"})
82
+ if json_out:
83
+ print_json({"results": results})
84
+ return
85
+ for r in results:
86
+ if r["status"] == "exists":
87
+ sys.stdout.write(f"{r['tool']}: already installed at {r['path']} (use --force to overwrite)\n")
88
+ else:
89
+ sys.stdout.write(f"{r['tool']}: installed → {r['path']}\n")
90
+
91
+
92
+ @app.command("uninstall")
93
+ def uninstall(
94
+ tool: Annotated[str, typer.Option("--tool", help="Which agent to target: claude, codex, or both")] = "both",
95
+ project: Annotated[bool, typer.Option("--project", help="Remove from the current directory instead of the home dir")] = False,
96
+ json_out: JsonOpt = False,
97
+ ) -> None:
98
+ """Remove the lql skill from Claude Code and Codex."""
99
+ tools = _parse_tools(tool)
100
+ if not tools:
101
+ print_error(f"Invalid --tool '{tool}'. Use claude, codex, or both.", "usage")
102
+ raise typer.Exit(1)
103
+ targets = _resolve_targets(tools, project)
104
+ results = []
105
+ for t in targets:
106
+ if t["dir"].exists():
107
+ shutil.rmtree(t["dir"], ignore_errors=True)
108
+ results.append({"tool": t["tool"], "path": str(t["dir"]), "status": "removed"})
109
+ else:
110
+ results.append({"tool": t["tool"], "path": str(t["dir"]), "status": "absent"})
111
+ if json_out:
112
+ print_json({"results": results})
113
+ return
114
+ for r in results:
115
+ state = "removed" if r["status"] == "removed" else "not installed"
116
+ sys.stdout.write(f"{r['tool']}: {state} ({r['path']})\n")
lql/commands/spec.py ADDED
@@ -0,0 +1,165 @@
1
+ import sys
2
+ from pathlib import Path
3
+ from typing import Annotated, Optional
4
+
5
+ import typer
6
+
7
+ from .._opts import ApiUrlOpt, JsonOpt, ProfileOpt
8
+ from ..api import ApiClient
9
+ from ..output import print_error, print_json
10
+ from ..util import q
11
+
12
+ app = typer.Typer(help="Manage workspace spec docs")
13
+
14
+ WorkspaceOpt = Annotated[str, typer.Option("--workspace", help="Workspace ID")]
15
+
16
+
17
+ @app.command("show")
18
+ def show(
19
+ workspace: WorkspaceOpt,
20
+ json_out: JsonOpt = False,
21
+ profile: ProfileOpt = None,
22
+ api_url: ApiUrlOpt = None,
23
+ ) -> None:
24
+ """Show the spec doc for a workspace."""
25
+ client = ApiClient(profile=profile, api_url=api_url)
26
+ doc = client.get(f"/v1/workspaces/{q(workspace)}/spec-doc").json()
27
+ if json_out:
28
+ print_json(doc or None)
29
+ return
30
+ if not doc:
31
+ sys.stdout.write(f"Workspace: {workspace}\nNo spec doc yet.\n")
32
+ return
33
+ cv = doc.get("current_version")
34
+ version_str = f"{cv.get('version')} ({cv.get('id')})" if cv else "none"
35
+ committed = cv.get("committed_at") if cv else ""
36
+ sys.stdout.write(f"Workspace: {workspace}\n")
37
+ sys.stdout.write(f"Version: {version_str}\n")
38
+ sys.stdout.write(f"Committed: {committed}\n\n")
39
+ sys.stdout.write(doc.get("content") or "(empty)\n")
40
+
41
+
42
+ @app.command("pull")
43
+ def pull(
44
+ workspace: WorkspaceOpt,
45
+ output: Annotated[Optional[str], typer.Option("-o", "--output", help="Output file (defaults to SPEC.md)")] = None,
46
+ stdout: Annotated[bool, typer.Option("--stdout", help="Print to stdout instead of writing a file")] = False,
47
+ profile: ProfileOpt = None,
48
+ api_url: ApiUrlOpt = None,
49
+ ) -> None:
50
+ """Pull the spec doc markdown to a file (defaults to SPEC.md)."""
51
+ client = ApiClient(profile=profile, api_url=api_url)
52
+ doc = client.get(f"/v1/workspaces/{q(workspace)}/spec-doc").json()
53
+ if not doc:
54
+ print_error(f"No spec doc exists for workspace {workspace}", "not_found")
55
+ raise typer.Exit(3)
56
+ content = doc.get("content") or ""
57
+ if stdout:
58
+ sys.stdout.write(content + "\n")
59
+ else:
60
+ out_file = output or "SPEC.md"
61
+ Path(out_file).resolve().write_text(content, "utf-8")
62
+ sys.stdout.write(f"Wrote spec doc to {out_file}\n")
63
+
64
+
65
+ @app.command("push")
66
+ def push(
67
+ workspace: WorkspaceOpt,
68
+ message: Annotated[str, typer.Option("--message", help="Commit message describing the change")],
69
+ file: Annotated[str, typer.Option("--file", help="Markdown file to push")] = "SPEC.md",
70
+ base_version_id: Annotated[Optional[str], typer.Option("--base-version-id", help="Base version ID (for conflict detection)")] = None,
71
+ json_out: JsonOpt = False,
72
+ profile: ProfileOpt = None,
73
+ api_url: ApiUrlOpt = None,
74
+ ) -> None:
75
+ """Push a spec doc markdown file to a workspace."""
76
+ file_path = Path(file).resolve()
77
+ if not file_path.exists():
78
+ print_error(f"File not found: {file_path}", "file_not_found")
79
+ raise typer.Exit(1)
80
+ content = file_path.read_text("utf-8")
81
+ client = ApiClient(profile=profile, api_url=api_url)
82
+
83
+ base = base_version_id
84
+ if not base:
85
+ cur = client.get(f"/v1/workspaces/{q(workspace)}/spec-doc").json()
86
+ base = (cur.get("current_version") or {}).get("id") if cur else None
87
+
88
+ if not base:
89
+ res = client.post(
90
+ f"/v1/workspaces/{q(workspace)}/spec-doc",
91
+ json={"content": content, "message": message},
92
+ ).json()
93
+ else:
94
+ res = client.put(
95
+ f"/v1/workspaces/{q(workspace)}/spec-doc",
96
+ json={"content": content, "base_version_id": base, "message": message},
97
+ ).json()
98
+ if json_out:
99
+ print_json(res)
100
+ else:
101
+ sys.stdout.write(f"Pushed spec doc. Version: {res.get('version', '?')} ({res.get('id', 'ok')})\n")
102
+
103
+
104
+ @app.command("history")
105
+ def history(
106
+ workspace: WorkspaceOpt,
107
+ json_out: JsonOpt = False,
108
+ profile: ProfileOpt = None,
109
+ api_url: ApiUrlOpt = None,
110
+ ) -> None:
111
+ """Show spec doc version history."""
112
+ client = ApiClient(profile=profile, api_url=api_url)
113
+ versions = client.get(f"/v1/workspaces/{q(workspace)}/spec-doc/versions").json()
114
+ if json_out:
115
+ print_json(versions)
116
+ else:
117
+ for v in versions:
118
+ sys.stdout.write(
119
+ f"v{v.get('version')} {v.get('id')} {v.get('committed_at') or ''} "
120
+ f"{v.get('committed_by_name') or ''} {v.get('message') or ''}\n"
121
+ )
122
+
123
+
124
+ @app.command("diff")
125
+ def diff(
126
+ workspace: WorkspaceOpt,
127
+ version_id: Annotated[str, typer.Option("--version-id", help="Version ID to show the diff for")],
128
+ compare_to: Annotated[Optional[str], typer.Option("--compare-to", help="Version to compare to (defaults to previous)")] = None,
129
+ json_out: JsonOpt = False,
130
+ profile: ProfileOpt = None,
131
+ api_url: ApiUrlOpt = None,
132
+ ) -> None:
133
+ """Show diff between spec doc versions."""
134
+ client = ApiClient(profile=profile, api_url=api_url)
135
+ params = {}
136
+ if compare_to:
137
+ params["compare_to"] = compare_to
138
+ res = client.get(
139
+ f"/v1/workspaces/{q(workspace)}/spec-doc/versions/{q(version_id)}/diff",
140
+ params=params,
141
+ ).json()
142
+ if json_out:
143
+ print_json(res)
144
+ else:
145
+ diff_text = res.get("unified_diff")
146
+ if isinstance(diff_text, str):
147
+ sys.stdout.write(diff_text + "\n")
148
+ else:
149
+ print_json(res)
150
+
151
+
152
+ @app.command("generate")
153
+ def generate(
154
+ workspace: WorkspaceOpt,
155
+ json_out: JsonOpt = False,
156
+ profile: ProfileOpt = None,
157
+ api_url: ApiUrlOpt = None,
158
+ ) -> None:
159
+ """Generate a spec doc from datasets."""
160
+ client = ApiClient(profile=profile, api_url=api_url)
161
+ res = client.post(f"/v1/workspaces/{q(workspace)}/spec-doc/generate").json()
162
+ if json_out:
163
+ print_json(res)
164
+ else:
165
+ sys.stdout.write(f"Generation started. Run ID: {res.get('run_id') or res.get('id') or 'ok'}\n")
@@ -0,0 +1,147 @@
1
+ import sys
2
+ from typing import Annotated
3
+
4
+ import typer
5
+
6
+ from .._opts import ApiUrlOpt, JsonOpt, ProfileOpt
7
+ from ..api import ApiClient
8
+ from ..output import print_json, print_table
9
+ from ..util import q
10
+
11
+ app = typer.Typer(help="Manage workspaces")
12
+ members_app = typer.Typer(help="Manage workspace members")
13
+ app.add_typer(members_app, name="members")
14
+
15
+
16
+ @app.command("list")
17
+ def list_workspaces(json_out: JsonOpt = False, profile: ProfileOpt = None, api_url: ApiUrlOpt = None) -> None:
18
+ """List all accessible workspaces."""
19
+ client = ApiClient(profile=profile, api_url=api_url)
20
+ items = client.get("/v1/workspaces").json()
21
+ print_table(
22
+ ["ID", "Name"],
23
+ [[w.get("id") or "", w.get("display_name") or w.get("name") or ""] for w in items],
24
+ json_out,
25
+ items,
26
+ )
27
+
28
+
29
+ @app.command("create")
30
+ def create(
31
+ name: Annotated[str, typer.Argument(help="Workspace name")],
32
+ json_out: JsonOpt = False,
33
+ profile: ProfileOpt = None,
34
+ api_url: ApiUrlOpt = None,
35
+ ) -> None:
36
+ """Create a new workspace."""
37
+ client = ApiClient(profile=profile, api_url=api_url)
38
+ data = client.post("/v1/workspaces", json={"name": name}).json()
39
+ if json_out:
40
+ print_json(data)
41
+ else:
42
+ sys.stdout.write(f"Created workspace: {data.get('id')}\n")
43
+
44
+
45
+ @app.command("show")
46
+ def show(
47
+ id: Annotated[str, typer.Argument(help="Workspace ID")],
48
+ json_out: JsonOpt = False,
49
+ profile: ProfileOpt = None,
50
+ api_url: ApiUrlOpt = None,
51
+ ) -> None:
52
+ """Show workspace details."""
53
+ client = ApiClient(profile=profile, api_url=api_url)
54
+ w = client.get(f"/v1/workspaces/{q(id)}").json()
55
+ if json_out:
56
+ print_json(w)
57
+ else:
58
+ print_table(
59
+ ["Field", "Value"],
60
+ [
61
+ ["ID", w.get("id") or ""],
62
+ ["Name", w.get("display_name") or w.get("name") or ""],
63
+ ["Created", w.get("created_at") or ""],
64
+ ],
65
+ False,
66
+ [w],
67
+ )
68
+
69
+
70
+ @app.command("update")
71
+ def update(
72
+ id: Annotated[str, typer.Argument(help="Workspace ID")],
73
+ name: Annotated[str, typer.Option("--name", help="New display name")],
74
+ json_out: JsonOpt = False,
75
+ profile: ProfileOpt = None,
76
+ api_url: ApiUrlOpt = None,
77
+ ) -> None:
78
+ """Update workspace properties."""
79
+ client = ApiClient(profile=profile, api_url=api_url)
80
+ data = client.patch(f"/v1/workspaces/{q(id)}", json={"name": name}).json()
81
+ if json_out:
82
+ print_json(data)
83
+ else:
84
+ sys.stdout.write(f"Updated workspace: {id}\n")
85
+
86
+
87
+ @app.command("delete")
88
+ def delete(
89
+ id: Annotated[str, typer.Argument(help="Workspace ID")],
90
+ profile: ProfileOpt = None,
91
+ api_url: ApiUrlOpt = None,
92
+ ) -> None:
93
+ """Delete a workspace."""
94
+ client = ApiClient(profile=profile, api_url=api_url)
95
+ client.delete(f"/v1/workspaces/{q(id)}")
96
+ sys.stdout.write(f"Deleted workspace: {id}\n")
97
+
98
+
99
+ @members_app.command("list")
100
+ def members_list(
101
+ id: Annotated[str, typer.Argument(help="Workspace ID")],
102
+ json_out: JsonOpt = False,
103
+ profile: ProfileOpt = None,
104
+ api_url: ApiUrlOpt = None,
105
+ ) -> None:
106
+ """List workspace members."""
107
+ client = ApiClient(profile=profile, api_url=api_url)
108
+ items = client.get(f"/v1/workspaces/{q(id)}/members").json()
109
+ print_table(
110
+ ["User ID", "Email", "Role"],
111
+ [
112
+ [m.get("user_id") or m.get("id") or "", m.get("email") or "", m.get("role") or ""]
113
+ for m in items
114
+ ],
115
+ json_out,
116
+ items,
117
+ )
118
+
119
+
120
+ @members_app.command("add")
121
+ def members_add(
122
+ id: Annotated[str, typer.Argument(help="Workspace ID")],
123
+ email: Annotated[str, typer.Argument(help="Member email")],
124
+ json_out: JsonOpt = False,
125
+ profile: ProfileOpt = None,
126
+ api_url: ApiUrlOpt = None,
127
+ ) -> None:
128
+ """Add a member to a workspace."""
129
+ client = ApiClient(profile=profile, api_url=api_url)
130
+ data = client.post(f"/v1/workspaces/{q(id)}/members", json={"email": email}).json()
131
+ if json_out:
132
+ print_json(data)
133
+ else:
134
+ sys.stdout.write(f"Added {email} to workspace {id}.\n")
135
+
136
+
137
+ @members_app.command("remove")
138
+ def members_remove(
139
+ id: Annotated[str, typer.Argument(help="Workspace ID")],
140
+ uid: Annotated[str, typer.Argument(help="User ID")],
141
+ profile: ProfileOpt = None,
142
+ api_url: ApiUrlOpt = None,
143
+ ) -> None:
144
+ """Remove a member from a workspace."""
145
+ client = ApiClient(profile=profile, api_url=api_url)
146
+ client.delete(f"/v1/workspaces/{q(id)}/members/{q(uid)}")
147
+ sys.stdout.write(f"Removed user {uid} from workspace {id}.\n")
lql/config.py ADDED
@@ -0,0 +1,103 @@
1
+ import ipaddress
2
+ import json
3
+ import os
4
+ import sys
5
+ from pathlib import Path
6
+ from typing import Optional
7
+ from urllib.parse import urlparse
8
+
9
+ CONFIG_DIR = Path.home() / ".lql"
10
+ CONFIG_FILE = CONFIG_DIR / "config.json"
11
+ DEFAULT_API_URL = "https://liquid-anchor-api.fly.dev"
12
+
13
+
14
+ def read_config() -> Optional[dict]:
15
+ try:
16
+ return json.loads(CONFIG_FILE.read_text("utf-8"))
17
+ except Exception:
18
+ return None
19
+
20
+
21
+ def write_config(config: dict) -> None:
22
+ # Always enforce 0700 on the dir (even if it pre-existed with weaker perms)
23
+ # and 0600 on the file — the file holds the API token.
24
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
25
+ os.chmod(CONFIG_DIR, 0o700)
26
+ CONFIG_FILE.write_text(json.dumps(config, indent=2), "utf-8")
27
+ os.chmod(CONFIG_FILE, 0o600)
28
+
29
+
30
+ def get_active_profile(profile_name: Optional[str] = None) -> Optional[dict]:
31
+ config = read_config()
32
+ if not config:
33
+ return None
34
+ name = profile_name or config.get("current_profile") or "default"
35
+ return (config.get("profiles") or {}).get(name)
36
+
37
+
38
+ def get_token(profile_name: Optional[str] = None) -> Optional[str]:
39
+ env_token = os.environ.get("LQL_API_KEY")
40
+ if env_token:
41
+ return env_token
42
+ profile = get_active_profile(profile_name)
43
+ return profile.get("token") if profile else None
44
+
45
+
46
+ _warned_hosts: set = set()
47
+
48
+
49
+ def _is_loopback(host: str) -> bool:
50
+ if host == "localhost":
51
+ return True
52
+ try:
53
+ # Covers the whole 127.0.0.0/8 range and ::1 (not just 127.0.0.1).
54
+ return ipaddress.ip_address(host.strip("[]")).is_loopback
55
+ except ValueError:
56
+ return False
57
+
58
+
59
+ def validate_api_url(raw_url: str) -> str:
60
+ """Validate an API base URL before it is ever used to send a bearer token.
61
+
62
+ The token is attached to every request to whatever origin is configured, so
63
+ an attacker-controllable URL (env var, copied --api-url, poisoned profile) is
64
+ a credential-exfiltration vector. We don't lock to one host (that would break
65
+ self-hosted/staging backends), but we enforce:
66
+ - only http/https (no file:, javascript:, ...)
67
+ - HTTPS required for non-loopback hosts, so the token never crosses the
68
+ network in plaintext. Opt out for trusted dev with LQL_ALLOW_INSECURE_API_URL=1.
69
+ A one-time stderr warning fires for non-default hosts so silent redirection
70
+ becomes visible.
71
+ """
72
+ parsed = urlparse(raw_url)
73
+ if not parsed.scheme or not parsed.netloc:
74
+ sys.stderr.write(f"lql: invalid API URL: {raw_url}\n")
75
+ raise SystemExit(2)
76
+
77
+ if parsed.scheme not in ("https", "http"):
78
+ sys.stderr.write(f"lql: unsupported API URL protocol '{parsed.scheme}:' (use https)\n")
79
+ raise SystemExit(2)
80
+
81
+ allow_insecure = os.environ.get("LQL_ALLOW_INSECURE_API_URL") == "1"
82
+ host = parsed.hostname or ""
83
+ if parsed.scheme == "http" and not _is_loopback(host) and not allow_insecure:
84
+ sys.stderr.write(
85
+ f"lql: refusing to send credentials to insecure (http) host '{parsed.netloc}'. "
86
+ "Use https, or set LQL_ALLOW_INSECURE_API_URL=1 to override.\n"
87
+ )
88
+ raise SystemExit(2)
89
+
90
+ default_host = urlparse(DEFAULT_API_URL).netloc
91
+ if parsed.netloc != default_host and parsed.netloc not in _warned_hosts:
92
+ _warned_hosts.add(parsed.netloc)
93
+ sys.stderr.write(f"lql: using non-default API host '{parsed.netloc}'\n")
94
+
95
+ return raw_url
96
+
97
+
98
+ def get_api_url(profile_name: Optional[str] = None) -> str:
99
+ env_url = os.environ.get("LQL_API_URL")
100
+ if env_url:
101
+ return validate_api_url(env_url)
102
+ profile = get_active_profile(profile_name)
103
+ return validate_api_url((profile or {}).get("api_url") or DEFAULT_API_URL)
lql/output.py ADDED
@@ -0,0 +1,29 @@
1
+ import json
2
+ import sys
3
+ from typing import List, Sequence
4
+
5
+ from rich.console import Console
6
+ from rich.table import Table
7
+
8
+ _console = Console()
9
+
10
+
11
+ def print_json(data: object) -> None:
12
+ sys.stdout.write(json.dumps(data, indent=2, default=str) + "\n")
13
+
14
+
15
+ def print_table(headers: Sequence[str], rows: Sequence[Sequence[str]], is_json: bool, data: object) -> None:
16
+ if is_json:
17
+ print_json(data)
18
+ return
19
+ table = Table(show_header=True, header_style="bold")
20
+ for h in headers:
21
+ table.add_column(str(h))
22
+ for row in rows:
23
+ table.add_row(*[str(c) for c in row])
24
+ _console.print(table)
25
+
26
+
27
+ def print_error(message: str, code: str) -> None:
28
+ # Compact, machine-readable; always to stderr (matches the TS contract).
29
+ sys.stderr.write(json.dumps({"error": message, "code": code}) + "\n")
lql/sessions.py ADDED
@@ -0,0 +1,27 @@
1
+ from typing import Optional
2
+
3
+ import typer
4
+
5
+ from .api import ApiClient
6
+ from .output import print_error
7
+ from .util import q
8
+
9
+
10
+ def resolve_session_id(
11
+ client: ApiClient,
12
+ dataset_id: Optional[str],
13
+ session_override: Optional[str] = None,
14
+ ) -> str:
15
+ """Resolve the review session to act on.
16
+
17
+ Annotations/highlights/reports hang off a per-dataset "active" session that
18
+ the API get-or-creates, so callers pass a dataset id and we resolve it.
19
+ `--session <id>` overrides this for the rare multi-pass case.
20
+ """
21
+ if session_override:
22
+ return session_override
23
+ if not dataset_id:
24
+ print_error("Dataset ID required (or pass --session <id>)", "missing_dataset")
25
+ raise typer.Exit(1)
26
+ res = client.post(f"/v1/datasets/{q(dataset_id)}/sessions/active")
27
+ return res.json()["id"]