lql-cli 0.4.0__tar.gz → 0.6.0__tar.gz

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 (35) hide show
  1. lql_cli-0.4.0/README.md → lql_cli-0.6.0/PKG-INFO +38 -6
  2. lql_cli-0.4.0/PKG-INFO → lql_cli-0.6.0/README.md +22 -21
  3. {lql_cli-0.4.0 → lql_cli-0.6.0}/pyproject.toml +3 -2
  4. lql_cli-0.6.0/src/lql/__init__.py +1 -0
  5. lql_cli-0.6.0/src/lql/_group.py +50 -0
  6. {lql_cli-0.4.0 → lql_cli-0.6.0}/src/lql/cli.py +3 -0
  7. {lql_cli-0.4.0 → lql_cli-0.6.0}/src/lql/commands/annotations.py +3 -1
  8. {lql_cli-0.4.0 → lql_cli-0.6.0}/src/lql/commands/auth.py +3 -1
  9. {lql_cli-0.4.0 → lql_cli-0.6.0}/src/lql/commands/buckets.py +3 -1
  10. {lql_cli-0.4.0 → lql_cli-0.6.0}/src/lql/commands/datasets.py +16 -1
  11. {lql_cli-0.4.0 → lql_cli-0.6.0}/src/lql/commands/edits.py +3 -1
  12. {lql_cli-0.4.0 → lql_cli-0.6.0}/src/lql/commands/evals.py +3 -1
  13. {lql_cli-0.4.0 → lql_cli-0.6.0}/src/lql/commands/highlights.py +3 -1
  14. {lql_cli-0.4.0 → lql_cli-0.6.0}/src/lql/commands/instructions.py +24 -3
  15. {lql_cli-0.4.0 → lql_cli-0.6.0}/src/lql/commands/issues.py +3 -1
  16. {lql_cli-0.4.0 → lql_cli-0.6.0}/src/lql/commands/preview.py +63 -5
  17. {lql_cli-0.4.0 → lql_cli-0.6.0}/src/lql/commands/reports.py +3 -1
  18. {lql_cli-0.4.0 → lql_cli-0.6.0}/src/lql/commands/skills.py +3 -1
  19. {lql_cli-0.4.0 → lql_cli-0.6.0}/src/lql/commands/spec.py +3 -1
  20. {lql_cli-0.4.0 → lql_cli-0.6.0}/src/lql/commands/tui.py +248 -20
  21. {lql_cli-0.4.0 → lql_cli-0.6.0}/src/lql/commands/workspaces.py +23 -4
  22. {lql_cli-0.4.0 → lql_cli-0.6.0}/uv.lock +109 -280
  23. lql_cli-0.4.0/src/lql/__init__.py +0 -1
  24. {lql_cli-0.4.0 → lql_cli-0.6.0}/.claude/settings.local.json +0 -0
  25. {lql_cli-0.4.0 → lql_cli-0.6.0}/.gitignore +0 -0
  26. {lql_cli-0.4.0 → lql_cli-0.6.0}/examples/agent-traces.jsonl +0 -0
  27. {lql_cli-0.4.0 → lql_cli-0.6.0}/package-lock.json +0 -0
  28. {lql_cli-0.4.0 → lql_cli-0.6.0}/src/lql/_opts.py +0 -0
  29. {lql_cli-0.4.0 → lql_cli-0.6.0}/src/lql/api.py +0 -0
  30. {lql_cli-0.4.0 → lql_cli-0.6.0}/src/lql/commands/__init__.py +0 -0
  31. {lql_cli-0.4.0 → lql_cli-0.6.0}/src/lql/commands/update.py +0 -0
  32. {lql_cli-0.4.0 → lql_cli-0.6.0}/src/lql/config.py +0 -0
  33. {lql_cli-0.4.0 → lql_cli-0.6.0}/src/lql/output.py +0 -0
  34. {lql_cli-0.4.0 → lql_cli-0.6.0}/src/lql/sessions.py +0 -0
  35. {lql_cli-0.4.0 → lql_cli-0.6.0}/src/lql/util.py +0 -0
@@ -1,8 +1,24 @@
1
+ Metadata-Version: 2.4
2
+ Name: lql-cli
3
+ Version: 0.6.0
4
+ Summary: lql — CLI for the Liquid DataViewer platform
5
+ Project-URL: Homepage, https://github.com/Liquid4All/lql
6
+ Author: Liquid AI
7
+ License: MIT
8
+ Requires-Python: >=3.12
9
+ Requires-Dist: httpx>=0.27
10
+ Requires-Dist: huggingface-hub>=0.24
11
+ Requires-Dist: rich>=13.0
12
+ Requires-Dist: textual-image>=0.7
13
+ Requires-Dist: textual>=0.50
14
+ Requires-Dist: typer>=0.12
15
+ Description-Content-Type: text/markdown
16
+
1
17
  # lql — Liquid Query Language CLI
2
18
 
3
19
  Scriptable CLI for the [Liquid DataViewer](https://dataviewer.liquid.ai) platform. Designed for both humans and AI agents (Claude Code, Codex, etc.) to automate datasets, eval analysis, spec docs, annotations, and more.
4
20
 
5
- A Python package (Python ≥ 3.9), published on PyPI as **[`lql-cli`](https://pypi.org/project/lql-cli/)** — the installed command is `lql`.
21
+ A Python package (Python ≥ 3.12), published on PyPI as **[`lql-cli`](https://pypi.org/project/lql-cli/)** — the installed command is `lql`.
6
22
 
7
23
  ## Quick start
8
24
 
@@ -14,7 +30,7 @@ lql skills install # teach Claude Code + Codex how to use lql
14
30
 
15
31
  ## Install
16
32
 
17
- `lql` is a Python package (requires Python ≥ 3.9), distributed on PyPI as **`lql-cli`** (the
33
+ `lql` is a Python package (requires Python ≥ 3.12), distributed on PyPI as **`lql-cli`** (the
18
34
  command it installs is `lql`). Install it as a standalone CLI tool with
19
35
  [uv](https://docs.astral.sh/uv/):
20
36
 
@@ -84,6 +100,13 @@ lql logout
84
100
 
85
101
  ## Command reference
86
102
 
103
+ Running a group with no subcommand (e.g. `lql datasets`) lists its subcommands.
104
+ For faster typing, **aliases** and **unique prefixes** resolve everywhere:
105
+ group aliases (`ds`, `ws`, `ev`, `ann`, `hl`, `rep`, `bkt`, `iss`), verb aliases
106
+ (`ls`=list, `rm`/`del`=delete, `new`/`mk`=create, `info`=show), and prefixes
107
+ (`lql datasets l` → `list`). So `lql ds ls --workspace <id>` ≡ `lql datasets list
108
+ --workspace <id>`. The canonical names below always work (use those in scripts).
109
+
87
110
  ### Auth
88
111
 
89
112
  ```
@@ -95,7 +118,7 @@ lql whoami Show current user
95
118
  ### Workspaces
96
119
 
97
120
  ```
98
- lql workspaces list List all workspaces
121
+ lql workspaces list [--search <text>] List workspaces (--search filters by name/slug)
99
122
  lql workspaces create <name> Create a workspace
100
123
  lql workspaces show <id> Show workspace details
101
124
  lql workspaces update <id> --name <n> Rename a workspace
@@ -108,7 +131,7 @@ lql workspaces members remove <id> <uid> Remove member by user ID
108
131
  ### Datasets
109
132
 
110
133
  ```
111
- lql datasets list [--workspace <id>] List datasets
134
+ lql datasets list [--workspace <id>] [--search <text>] List datasets (--search filters by name/HF repo)
112
135
  lql datasets show <id> Show dataset details
113
136
  lql datasets create --workspace <id> --hf-repo <repo> [--name <n>] [--split <s>]
114
137
  lql datasets create --workspace <id> --hf-bucket <org/bucket> --key <path-or-glob> [--name <n>]
@@ -158,11 +181,20 @@ lql preview tatsu-lab/alpaca --hf
158
181
  lql preview org/name --hf --split validation --workspace <id>
159
182
  ```
160
183
 
161
- **Navigation** two modes, toggle with `m` / `Tab`:
184
+ **Media.** Images render **inline** in terminals that support an image protocol
185
+ (Kitty/Ghostty, iTerm2, Sixel; falls back to a compact `🖼 …` placeholder
186
+ elsewhere) — both multimodal `image_url`/data-URI segments and image-mode
187
+ columns. Audio can't play inline in a terminal, so each clip shows a `♪` line
188
+ and **`p` plays** the current sample's audio via the system player (`afplay`/
189
+ `open`). Images render inline in pager mode (one sample at a time); scroll mode
190
+ shows placeholders to avoid decoding the whole buffer.
191
+
192
+ **Navigation** — two modes, toggle with `m`:
162
193
 
163
194
  - **pager** (default): one sample at a time · `←/→` or `n`/`b` switch samples · `↑/↓`/`j`/`k`/PgUp-Dn scroll
164
195
  - **scroll**: all samples in one buffer · `n`/`b` jump between samples · arrows scroll
165
- - `q` quits
196
+ - **copy**: `Tab`/`Shift+Tab` move a highlight between blocks · `c` copies the focused message/field · `Y` copies the whole sample as JSON (via OSC 52 — reaches your **local** clipboard over SSH where the terminal supports it, e.g. Ghostty/iTerm2; not macOS Terminal)
197
+ - `p` play audio · `q` quits
166
198
 
167
199
  ```
168
200
  lql preview examples/agent-traces.jsonl # 20-sample file of agent-trace/tool-use formats
@@ -1,23 +1,8 @@
1
- Metadata-Version: 2.4
2
- Name: lql-cli
3
- Version: 0.4.0
4
- Summary: lql — CLI for the Liquid DataViewer platform
5
- Project-URL: Homepage, https://github.com/Liquid4All/lql
6
- Author: Liquid AI
7
- License: MIT
8
- Requires-Python: >=3.9
9
- Requires-Dist: httpx>=0.27
10
- Requires-Dist: huggingface-hub>=0.24
11
- Requires-Dist: rich>=13.0
12
- Requires-Dist: textual>=0.50
13
- Requires-Dist: typer>=0.12
14
- Description-Content-Type: text/markdown
15
-
16
1
  # lql — Liquid Query Language CLI
17
2
 
18
3
  Scriptable CLI for the [Liquid DataViewer](https://dataviewer.liquid.ai) platform. Designed for both humans and AI agents (Claude Code, Codex, etc.) to automate datasets, eval analysis, spec docs, annotations, and more.
19
4
 
20
- A Python package (Python ≥ 3.9), published on PyPI as **[`lql-cli`](https://pypi.org/project/lql-cli/)** — the installed command is `lql`.
5
+ A Python package (Python ≥ 3.12), published on PyPI as **[`lql-cli`](https://pypi.org/project/lql-cli/)** — the installed command is `lql`.
21
6
 
22
7
  ## Quick start
23
8
 
@@ -29,7 +14,7 @@ lql skills install # teach Claude Code + Codex how to use lql
29
14
 
30
15
  ## Install
31
16
 
32
- `lql` is a Python package (requires Python ≥ 3.9), distributed on PyPI as **`lql-cli`** (the
17
+ `lql` is a Python package (requires Python ≥ 3.12), distributed on PyPI as **`lql-cli`** (the
33
18
  command it installs is `lql`). Install it as a standalone CLI tool with
34
19
  [uv](https://docs.astral.sh/uv/):
35
20
 
@@ -99,6 +84,13 @@ lql logout
99
84
 
100
85
  ## Command reference
101
86
 
87
+ Running a group with no subcommand (e.g. `lql datasets`) lists its subcommands.
88
+ For faster typing, **aliases** and **unique prefixes** resolve everywhere:
89
+ group aliases (`ds`, `ws`, `ev`, `ann`, `hl`, `rep`, `bkt`, `iss`), verb aliases
90
+ (`ls`=list, `rm`/`del`=delete, `new`/`mk`=create, `info`=show), and prefixes
91
+ (`lql datasets l` → `list`). So `lql ds ls --workspace <id>` ≡ `lql datasets list
92
+ --workspace <id>`. The canonical names below always work (use those in scripts).
93
+
102
94
  ### Auth
103
95
 
104
96
  ```
@@ -110,7 +102,7 @@ lql whoami Show current user
110
102
  ### Workspaces
111
103
 
112
104
  ```
113
- lql workspaces list List all workspaces
105
+ lql workspaces list [--search <text>] List workspaces (--search filters by name/slug)
114
106
  lql workspaces create <name> Create a workspace
115
107
  lql workspaces show <id> Show workspace details
116
108
  lql workspaces update <id> --name <n> Rename a workspace
@@ -123,7 +115,7 @@ lql workspaces members remove <id> <uid> Remove member by user ID
123
115
  ### Datasets
124
116
 
125
117
  ```
126
- lql datasets list [--workspace <id>] List datasets
118
+ lql datasets list [--workspace <id>] [--search <text>] List datasets (--search filters by name/HF repo)
127
119
  lql datasets show <id> Show dataset details
128
120
  lql datasets create --workspace <id> --hf-repo <repo> [--name <n>] [--split <s>]
129
121
  lql datasets create --workspace <id> --hf-bucket <org/bucket> --key <path-or-glob> [--name <n>]
@@ -173,11 +165,20 @@ lql preview tatsu-lab/alpaca --hf
173
165
  lql preview org/name --hf --split validation --workspace <id>
174
166
  ```
175
167
 
176
- **Navigation** two modes, toggle with `m` / `Tab`:
168
+ **Media.** Images render **inline** in terminals that support an image protocol
169
+ (Kitty/Ghostty, iTerm2, Sixel; falls back to a compact `🖼 …` placeholder
170
+ elsewhere) — both multimodal `image_url`/data-URI segments and image-mode
171
+ columns. Audio can't play inline in a terminal, so each clip shows a `♪` line
172
+ and **`p` plays** the current sample's audio via the system player (`afplay`/
173
+ `open`). Images render inline in pager mode (one sample at a time); scroll mode
174
+ shows placeholders to avoid decoding the whole buffer.
175
+
176
+ **Navigation** — two modes, toggle with `m`:
177
177
 
178
178
  - **pager** (default): one sample at a time · `←/→` or `n`/`b` switch samples · `↑/↓`/`j`/`k`/PgUp-Dn scroll
179
179
  - **scroll**: all samples in one buffer · `n`/`b` jump between samples · arrows scroll
180
- - `q` quits
180
+ - **copy**: `Tab`/`Shift+Tab` move a highlight between blocks · `c` copies the focused message/field · `Y` copies the whole sample as JSON (via OSC 52 — reaches your **local** clipboard over SSH where the terminal supports it, e.g. Ghostty/iTerm2; not macOS Terminal)
181
+ - `p` play audio · `q` quits
181
182
 
182
183
  ```
183
184
  lql preview examples/agent-traces.jsonl # 20-sample file of agent-trace/tool-use formats
@@ -4,10 +4,10 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "lql-cli"
7
- version = "0.4.0"
7
+ version = "0.6.0"
8
8
  description = "lql — CLI for the Liquid DataViewer platform"
9
9
  readme = "README.md"
10
- requires-python = ">=3.9"
10
+ requires-python = ">=3.12"
11
11
  license = { text = "MIT" }
12
12
  authors = [{ name = "Liquid AI" }]
13
13
  dependencies = [
@@ -16,6 +16,7 @@ dependencies = [
16
16
  "rich>=13.0",
17
17
  "huggingface-hub>=0.24",
18
18
  "textual>=0.50",
19
+ "textual-image>=0.7",
19
20
  ]
20
21
 
21
22
  [project.scripts]
@@ -0,0 +1 @@
1
+ __version__ = "0.6.0"
@@ -0,0 +1,50 @@
1
+ """A Typer/Click group that adds two conveniences to every command group:
2
+
3
+ * short aliases — `ds`→`datasets`, `ws`→`workspaces`, `ls`→`list`, …
4
+ * unique-prefix matching — `lql datasets l` resolves to `list`
5
+
6
+ Both are purely additive: the canonical command names always work, and
7
+ `lql instructions` documents the canonical forms, so agents are unaffected —
8
+ these are conveniences for humans typing at a prompt.
9
+ """
10
+
11
+ from typer.core import TyperGroup
12
+
13
+ # alias -> canonical. Kept intentionally small and unambiguous.
14
+ ALIASES = {
15
+ # command groups
16
+ "ds": "datasets",
17
+ "ws": "workspaces",
18
+ "ev": "eval",
19
+ "evals": "eval",
20
+ "ann": "annotations",
21
+ "hl": "highlights",
22
+ "rep": "reports",
23
+ "bkt": "buckets",
24
+ "iss": "issues",
25
+ # verbs
26
+ "ls": "list",
27
+ "rm": "delete",
28
+ "del": "delete",
29
+ "new": "create",
30
+ "mk": "create",
31
+ "info": "show",
32
+ }
33
+
34
+
35
+ class AliasGroup(TyperGroup):
36
+ def get_command(self, ctx, name):
37
+ # Exact name first so a real command is never shadowed by an alias;
38
+ # then explicit alias; then an unambiguous prefix.
39
+ cmd = super().get_command(ctx, name)
40
+ if cmd is not None:
41
+ return cmd
42
+ alias = ALIASES.get(name)
43
+ if alias is not None:
44
+ cmd = super().get_command(ctx, alias)
45
+ if cmd is not None:
46
+ return cmd
47
+ matches = [c for c in self.list_commands(ctx) if c.startswith(name)]
48
+ if len(matches) == 1:
49
+ return super().get_command(ctx, matches[0])
50
+ return None
@@ -3,6 +3,8 @@ from typing import Annotated, Optional
3
3
 
4
4
  import typer
5
5
 
6
+ from ._group import AliasGroup
7
+
6
8
  from . import __version__
7
9
  from .output import print_error
8
10
  from .commands import (
@@ -32,6 +34,7 @@ app = typer.Typer(
32
34
  ),
33
35
  no_args_is_help=True,
34
36
  add_completion=False,
37
+ cls=AliasGroup,
35
38
  )
36
39
 
37
40
 
@@ -3,13 +3,15 @@ from typing import Annotated, Optional
3
3
 
4
4
  import typer
5
5
 
6
+ from .._group import AliasGroup
7
+
6
8
  from .._opts import ApiUrlOpt, JsonOpt, ProfileOpt
7
9
  from ..api import ApiClient
8
10
  from ..output import print_json, print_table
9
11
  from ..sessions import resolve_session_id
10
12
  from ..util import q
11
13
 
12
- app = typer.Typer(help="Manage annotations")
14
+ app = typer.Typer(help="Manage annotations", cls=AliasGroup, no_args_is_help=True)
13
15
 
14
16
  SessionOpt = Annotated[Optional[str], typer.Option("--session", help="Target a specific review session (advanced)")]
15
17
 
@@ -7,6 +7,8 @@ from typing import Annotated, Optional
7
7
  import httpx
8
8
  import typer
9
9
 
10
+ from .._group import AliasGroup
11
+
10
12
  from .._opts import ApiUrlOpt, JsonOpt, ProfileOpt
11
13
  from ..api import ApiClient
12
14
  from ..config import get_api_url, read_config, validate_api_url, write_config
@@ -148,7 +150,7 @@ def whoami(
148
150
  )
149
151
 
150
152
 
151
- auth_app = typer.Typer(help="Authentication commands")
153
+ auth_app = typer.Typer(help="Authentication commands", cls=AliasGroup, no_args_is_help=True)
152
154
  auth_app.command("login")(login)
153
155
  auth_app.command("logout")(logout)
154
156
  auth_app.command("whoami")(whoami)
@@ -4,12 +4,14 @@ from typing import Annotated, Optional
4
4
 
5
5
  import typer
6
6
 
7
+ from .._group import AliasGroup
8
+
7
9
  from .._opts import ApiUrlOpt, JsonOpt, ProfileOpt
8
10
  from ..api import ApiClient
9
11
  from ..output import print_json, print_table
10
12
  from ..util import q
11
13
 
12
- app = typer.Typer(help="Manage S3 and Hugging Face buckets")
14
+ app = typer.Typer(help="Manage S3 and Hugging Face buckets", cls=AliasGroup, no_args_is_help=True)
13
15
 
14
16
 
15
17
  @app.command("list")
@@ -6,12 +6,14 @@ from typing import Annotated, Optional
6
6
 
7
7
  import typer
8
8
 
9
+ from .._group import AliasGroup
10
+
9
11
  from .._opts import ApiUrlOpt, JsonOpt, ProfileOpt
10
12
  from ..api import ApiClient
11
13
  from ..output import print_error, print_json, print_table
12
14
  from ..util import q
13
15
 
14
- app = typer.Typer(help="Manage datasets")
16
+ app = typer.Typer(help="Manage datasets", cls=AliasGroup, no_args_is_help=True)
15
17
 
16
18
 
17
19
  def _truncate(v: object, n: int = 80) -> str:
@@ -22,6 +24,10 @@ def _truncate(v: object, n: int = 80) -> str:
22
24
  @app.command("list")
23
25
  def list_datasets(
24
26
  workspace: Annotated[Optional[str], typer.Option("--workspace", help="Filter by workspace ID")] = None,
27
+ search: Annotated[
28
+ Optional[str],
29
+ typer.Option("--search", "-s", help="Filter by case-insensitive substring of name / HF repo"),
30
+ ] = None,
25
31
  json_out: JsonOpt = False,
26
32
  profile: ProfileOpt = None,
27
33
  api_url: ApiUrlOpt = None,
@@ -32,6 +38,15 @@ def list_datasets(
32
38
  if workspace:
33
39
  params["workspace_id"] = workspace
34
40
  items = client.get("/v1/datasets", params=params).json()
41
+ if search:
42
+ s = search.lower()
43
+ items = [
44
+ d
45
+ for d in items
46
+ if s in (d.get("display_name") or "").lower()
47
+ or s in (d.get("name") or "").lower()
48
+ or s in (d.get("hf_repo_id") or "").lower()
49
+ ]
35
50
  print_table(
36
51
  ["ID", "Name", "Workspace", "Rows"],
37
52
  [
@@ -4,12 +4,14 @@ from typing import Annotated, Optional
4
4
 
5
5
  import typer
6
6
 
7
+ from .._group import AliasGroup
8
+
7
9
  from .._opts import ApiUrlOpt, JsonOpt, ProfileOpt
8
10
  from ..api import ApiClient
9
11
  from ..output import print_error, print_json, print_table
10
12
  from ..util import q
11
13
 
12
- app = typer.Typer(help="Manage row edits")
14
+ app = typer.Typer(help="Manage row edits", cls=AliasGroup, no_args_is_help=True)
13
15
 
14
16
 
15
17
  @app.command("list")
@@ -6,12 +6,14 @@ from typing import Annotated, List, Optional
6
6
 
7
7
  import typer
8
8
 
9
+ from .._group import AliasGroup
10
+
9
11
  from .._opts import ApiUrlOpt, JsonOpt, ProfileOpt
10
12
  from ..api import ApiClient
11
13
  from ..output import print_error, print_json, print_table
12
14
  from ..util import q
13
15
 
14
- app = typer.Typer(help="Inspect and analyze eval datasets (accuracy, failure modes, samples)")
16
+ app = typer.Typer(help="Inspect and analyze eval datasets (accuracy, failure modes, samples)", cls=AliasGroup, no_args_is_help=True)
15
17
 
16
18
  # Mirrors front/src/lib/eval-dataset.ts so `eval samples --search` searches the
17
19
  # same prompt/response columns the eval views do.
@@ -3,13 +3,15 @@ from typing import Annotated, Optional
3
3
 
4
4
  import typer
5
5
 
6
+ from .._group import AliasGroup
7
+
6
8
  from .._opts import ApiUrlOpt, JsonOpt, ProfileOpt
7
9
  from ..api import ApiClient
8
10
  from ..output import print_error, print_json, print_table
9
11
  from ..sessions import resolve_session_id
10
12
  from ..util import q
11
13
 
12
- app = typer.Typer(help="Manage highlights")
14
+ app = typer.Typer(help="Manage highlights", cls=AliasGroup, no_args_is_help=True)
13
15
 
14
16
  SessionOpt = Annotated[Optional[str], typer.Option("--session", help="Target a specific review session (advanced)")]
15
17
 
@@ -8,6 +8,15 @@ INSTRUCTIONS = r"""
8
8
  CLI for the DataViewer platform. Gives agents and humans complete scriptable
9
9
  control over workspaces, datasets, spec docs, annotations, and S3.
10
10
 
11
+ Running a group with no subcommand (e.g. `lql datasets`) lists its subcommands.
12
+
13
+ Shortcuts (for humans typing): group aliases (`ds`=datasets, `ws`=workspaces,
14
+ `ev`=eval, `ann`=annotations, `hl`=highlights, `rep`=reports, `bkt`=buckets,
15
+ `iss`=issues), verb aliases (`ls`=list, `rm`/`del`=delete, `new`/`mk`=create,
16
+ `info`=show), and unique prefixes (`lql datasets l` → list) all resolve.
17
+ AGENTS: use the full canonical names shown throughout this reference — they are
18
+ the stable contract; the aliases are conveniences layered on top.
19
+
11
20
  ## Authentication
12
21
 
13
22
  lql login # Open browser → click Authorize → token stored automatically
@@ -43,7 +52,7 @@ Pagination: --limit N --offset N on list commands.
43
52
 
44
53
  A workspace is the top-level container for datasets, spec docs, and members.
45
54
 
46
- lql workspaces list
55
+ lql workspaces list [--search <text>] # --search filters by name / slug substring
47
56
  lql workspaces create <name>
48
57
  lql workspaces show <id>
49
58
  lql workspaces update <id> --name <new-name>
@@ -54,7 +63,7 @@ A workspace is the top-level container for datasets, spec docs, and members.
54
63
 
55
64
  ## Datasets
56
65
 
57
- lql datasets list [--workspace <id>]
66
+ lql datasets list [--workspace <id>] [--search <text>] # --search filters by name / HF repo substring
58
67
  lql datasets show <id>
59
68
  lql datasets create --workspace <id> --hf-repo <org/repo> [--name <display>] [--split <split>]
60
69
  lql datasets create --workspace <id> --hf-bucket <org/bucket> --key <path-or-glob> [--name <display>]
@@ -94,10 +103,20 @@ Options: -c/--column (field(s) to treat as conversations; default auto-detect,
94
103
  repeatable), -n/--limit (page size when paging a platform dataset), --offset
95
104
  (start row index), --title, --hf, --split, --workspace, --profile, --api-url.
96
105
 
97
- Navigation: two modes toggled with m/Tab — pager (one sample at a time; ←/→ or
106
+ Navigation: two modes toggled with m — pager (one sample at a time; ←/→ or
98
107
  n/b switch samples, ↑/↓/j/k scroll) and scroll (all samples; n/b jump between
99
108
  them). q quits. Works over plain SSH with no browser or port-forward.
100
109
 
110
+ Media: images render inline in image-capable terminals (Kitty/Ghostty, iTerm2,
111
+ Sixel; placeholder otherwise) for both multimodal image segments and image-mode
112
+ columns; audio shows a ♪ line and `p` plays the current sample's clip via the
113
+ system player (afplay/open).
114
+
115
+ Copy: Tab/Shift+Tab move a highlight between blocks, `c` copies the focused
116
+ message/field, `Y` copies the whole sample as JSON — via OSC 52, reaching the
117
+ local clipboard over SSH where the terminal supports it (e.g. Ghostty/iTerm2;
118
+ not macOS Terminal).
119
+
101
120
  ## Evals
102
121
 
103
122
  Eval datasets (evaluation-run output: each row a sample with a model 'response'
@@ -220,7 +239,9 @@ never goes stale.
220
239
 
221
240
  ### Discover workspaces and datasets
222
241
  lql workspaces list --json
242
+ lql workspaces list --search <name> --json # find a workspace by name
223
243
  lql datasets list --workspace <id> --json
244
+ lql datasets list --search <name> --json # find a dataset by name / HF repo
224
245
 
225
246
  ### Read dataset contents
226
247
  lql datasets schema <id> --json
@@ -3,12 +3,14 @@ from typing import Annotated, Optional
3
3
 
4
4
  import typer
5
5
 
6
+ from .._group import AliasGroup
7
+
6
8
  from .._opts import ApiUrlOpt, JsonOpt, ProfileOpt
7
9
  from ..api import ApiClient
8
10
  from ..output import print_json, print_table
9
11
  from ..util import q
10
12
 
11
- app = typer.Typer(help="Manage issues")
13
+ app = typer.Typer(help="Manage issues", cls=AliasGroup, no_args_is_help=True)
12
14
 
13
15
 
14
16
  @app.command("list")
@@ -35,6 +35,9 @@ _KNOWN_ROLES = {"user", "assistant", "system", "tool"}
35
35
  _AUDIO_EXT_RE = re.compile(r"\.(wav|mp3|ogg|flac|m4a|aac|opus|webm)(\?|$)", re.I)
36
36
  _AUDIO_KEYS = ["audio", "audio_url", "audio_data", "input_audio", "src", "url"]
37
37
 
38
+ _IMAGE_EXT_RE = re.compile(r"\.(png|jpe?g|gif|webp|bmp|svg)(\?|$)", re.I)
39
+ _IMAGE_KEYS = ["image", "image_url", "image_data", "img", "src", "url"]
40
+
38
41
 
39
42
  def _as_audio_url(v: object, key_hint: Optional[str] = None) -> Optional[str]:
40
43
  if isinstance(v, str) and v:
@@ -74,6 +77,58 @@ def _segment_audio_url(seg: dict) -> Optional[str]:
74
77
  return None
75
78
 
76
79
 
80
+ def _as_image_url(v: object, key_hint: Optional[str] = None) -> Optional[str]:
81
+ """Mirror of _as_audio_url for images. Returns a data: URI or http(s) image
82
+ URL. Also handles HF Image structs ({bytes,path}) and {url}/{src} wrappers."""
83
+ if isinstance(v, str) and v:
84
+ if v.startswith("data:image/"):
85
+ return v
86
+ is_http = bool(re.match(r"^https?://", v, re.I))
87
+ if is_http and _IMAGE_EXT_RE.search(v):
88
+ return v
89
+ # Under an explicitly image-named key, accept an extensionless URL too
90
+ # (signed S3/HF/CDN links often have no .png/.jpg suffix).
91
+ if key_hint and "image" in key_hint.lower():
92
+ if is_http:
93
+ return v
94
+ if re.fullmatch(r"[A-Za-z0-9+/=\n]{100,}", v):
95
+ return f"data:image/png;base64,{v}"
96
+ return None
97
+ if isinstance(v, dict):
98
+ url = (v.get("url") if isinstance(v.get("url"), str) else None) or (
99
+ v.get("src") if isinstance(v.get("src"), str) else None
100
+ )
101
+ if url:
102
+ resolved = _as_image_url(url, key_hint) # keep the hint so {image_url:{url}} resolves
103
+ if resolved:
104
+ return resolved
105
+ # HF datasets Image struct: {"bytes": <base64-or-bytes>, "path": ...}
106
+ b = v.get("bytes")
107
+ if isinstance(b, str) and b:
108
+ return b if b.startswith("data:") else f"data:image/png;base64,{b}"
109
+ return None
110
+
111
+
112
+ def _segment_image_url(seg: dict) -> Optional[str]:
113
+ for key in _IMAGE_KEYS:
114
+ got = _as_image_url(seg.get(key), key)
115
+ if got:
116
+ return got
117
+ t = seg.get("type") if isinstance(seg.get("type"), str) else ""
118
+ if "image" in t.lower():
119
+ for v in seg.values():
120
+ got = _as_image_url(v, "image")
121
+ if got:
122
+ return got
123
+ return None
124
+
125
+
126
+ def _is_image_string(s: object) -> bool:
127
+ """True for a plain value that is itself an image (data: URI or image URL) —
128
+ used to render top-level image-mode columns instead of a giant text blob."""
129
+ return isinstance(s, str) and (s.startswith("data:image/") or bool(re.match(r"^https?://", s, re.I) and _IMAGE_EXT_RE.search(s)))
130
+
131
+
77
132
  def _is_structured_content(val: object) -> bool:
78
133
  # Anchor recognizes text + audio segments; we also accept any explicitly
79
134
  # typed segment (e.g. {type:"image_url", ...}) so vision traces render too.
@@ -128,8 +183,8 @@ def _is_conversation(val: object) -> bool:
128
183
  def _flatten_structured(segs: List[dict]) -> str:
129
184
  parts = []
130
185
  for seg in segs:
131
- if _segment_audio_url(seg):
132
- continue # surfaced separately as an <audio> player
186
+ if _segment_audio_url(seg) or _segment_image_url(seg):
187
+ continue # surfaced separately (audio player / inline image)
133
188
  text = seg.get("text") if isinstance(seg.get("text"), str) else ""
134
189
  t = seg.get("type")
135
190
  if t and t != "text":
@@ -147,6 +202,7 @@ def _parse_messages(raw: List[dict]) -> List[dict]:
147
202
  for msg in raw:
148
203
  shape = _message_shape(msg)
149
204
  audio = None
205
+ images = None
150
206
  tool_calls = None
151
207
  if shape == "from-value":
152
208
  role, content = str(msg.get("from") or ""), str(msg.get("value") or "")
@@ -154,14 +210,16 @@ def _parse_messages(raw: List[dict]) -> List[dict]:
154
210
  role = str(msg.get("role") or "")
155
211
  segs = msg.get("content") or []
156
212
  content = _flatten_structured(segs)
157
- urls = [u for u in (_segment_audio_url(s) for s in segs) if u]
158
- audio = urls or None
213
+ audio = [u for u in (_segment_audio_url(s) for s in segs) if u] or None
214
+ images = [u for u in (_segment_image_url(s) for s in segs) if u] or None
159
215
  else:
160
216
  role, content = str(msg.get("role") or ""), str(msg.get("content") or "")
161
217
  if isinstance(msg.get("tool_calls"), list) and msg["tool_calls"]:
162
218
  tool_calls = _normalize_tool_calls(msg["tool_calls"])
163
219
  extras = {k: v for k, v in msg.items() if k not in _CONV_BASE_KEYS and v not in (None, "")}
164
- out.append({"role": role, "content": content, "audio": audio, "tool_calls": tool_calls, "extras": extras or None})
220
+ out.append(
221
+ {"role": role, "content": content, "audio": audio, "images": images, "tool_calls": tool_calls, "extras": extras or None}
222
+ )
165
223
  return out
166
224
 
167
225
 
@@ -3,13 +3,15 @@ from typing import Annotated, Optional
3
3
 
4
4
  import typer
5
5
 
6
+ from .._group import AliasGroup
7
+
6
8
  from .._opts import ApiUrlOpt, JsonOpt, ProfileOpt
7
9
  from ..api import ApiClient
8
10
  from ..output import print_json, print_table
9
11
  from ..sessions import resolve_session_id
10
12
  from ..util import q
11
13
 
12
- app = typer.Typer(help="Manage reports")
14
+ app = typer.Typer(help="Manage reports", cls=AliasGroup, no_args_is_help=True)
13
15
 
14
16
  SessionOpt = Annotated[Optional[str], typer.Option("--session", help="Target a specific review session (advanced)")]
15
17
 
@@ -5,10 +5,12 @@ from typing import Annotated, List, Optional
5
5
 
6
6
  import typer
7
7
 
8
+ from .._group import AliasGroup
9
+
8
10
  from .._opts import JsonOpt
9
11
  from ..output import print_error, print_json
10
12
 
11
- app = typer.Typer(help="Install the lql agent skill into Claude Code and Codex")
13
+ app = typer.Typer(help="Install the lql agent skill into Claude Code and Codex", cls=AliasGroup, no_args_is_help=True)
12
14
 
13
15
  # Thin pointer skill. The body deliberately does NOT embed the full reference —
14
16
  # it tells the agent to run `lql instructions`, which is always in sync with the
@@ -4,12 +4,14 @@ from typing import Annotated, Optional
4
4
 
5
5
  import typer
6
6
 
7
+ from .._group import AliasGroup
8
+
7
9
  from .._opts import ApiUrlOpt, JsonOpt, ProfileOpt
8
10
  from ..api import ApiClient
9
11
  from ..output import print_error, print_json
10
12
  from ..util import q
11
13
 
12
- app = typer.Typer(help="Manage workspace spec docs")
14
+ app = typer.Typer(help="Manage workspace spec docs", cls=AliasGroup, no_args_is_help=True)
13
15
 
14
16
  WorkspaceOpt = Annotated[str, typer.Option("--workspace", help="Workspace ID")]
15
17