lql-cli 0.6.0__tar.gz → 0.8.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.6.0 → lql_cli-0.8.0}/PKG-INFO +13 -1
  2. {lql_cli-0.6.0 → lql_cli-0.8.0}/README.md +12 -0
  3. {lql_cli-0.6.0 → lql_cli-0.8.0}/pyproject.toml +1 -1
  4. lql_cli-0.8.0/src/lql/__init__.py +1 -0
  5. {lql_cli-0.6.0 → lql_cli-0.8.0}/src/lql/commands/datasets.py +72 -3
  6. {lql_cli-0.6.0 → lql_cli-0.8.0}/src/lql/commands/instructions.py +20 -4
  7. {lql_cli-0.6.0 → lql_cli-0.8.0}/src/lql/commands/preview.py +93 -7
  8. {lql_cli-0.6.0 → lql_cli-0.8.0}/src/lql/commands/workspaces.py +64 -3
  9. {lql_cli-0.6.0 → lql_cli-0.8.0}/src/lql/output.py +21 -1
  10. {lql_cli-0.6.0 → lql_cli-0.8.0}/uv.lock +1 -1
  11. lql_cli-0.6.0/src/lql/__init__.py +0 -1
  12. {lql_cli-0.6.0 → lql_cli-0.8.0}/.claude/settings.local.json +0 -0
  13. {lql_cli-0.6.0 → lql_cli-0.8.0}/.gitignore +0 -0
  14. {lql_cli-0.6.0 → lql_cli-0.8.0}/examples/agent-traces.jsonl +0 -0
  15. {lql_cli-0.6.0 → lql_cli-0.8.0}/package-lock.json +0 -0
  16. {lql_cli-0.6.0 → lql_cli-0.8.0}/src/lql/_group.py +0 -0
  17. {lql_cli-0.6.0 → lql_cli-0.8.0}/src/lql/_opts.py +0 -0
  18. {lql_cli-0.6.0 → lql_cli-0.8.0}/src/lql/api.py +0 -0
  19. {lql_cli-0.6.0 → lql_cli-0.8.0}/src/lql/cli.py +0 -0
  20. {lql_cli-0.6.0 → lql_cli-0.8.0}/src/lql/commands/__init__.py +0 -0
  21. {lql_cli-0.6.0 → lql_cli-0.8.0}/src/lql/commands/annotations.py +0 -0
  22. {lql_cli-0.6.0 → lql_cli-0.8.0}/src/lql/commands/auth.py +0 -0
  23. {lql_cli-0.6.0 → lql_cli-0.8.0}/src/lql/commands/buckets.py +0 -0
  24. {lql_cli-0.6.0 → lql_cli-0.8.0}/src/lql/commands/edits.py +0 -0
  25. {lql_cli-0.6.0 → lql_cli-0.8.0}/src/lql/commands/evals.py +0 -0
  26. {lql_cli-0.6.0 → lql_cli-0.8.0}/src/lql/commands/highlights.py +0 -0
  27. {lql_cli-0.6.0 → lql_cli-0.8.0}/src/lql/commands/issues.py +0 -0
  28. {lql_cli-0.6.0 → lql_cli-0.8.0}/src/lql/commands/reports.py +0 -0
  29. {lql_cli-0.6.0 → lql_cli-0.8.0}/src/lql/commands/skills.py +0 -0
  30. {lql_cli-0.6.0 → lql_cli-0.8.0}/src/lql/commands/spec.py +0 -0
  31. {lql_cli-0.6.0 → lql_cli-0.8.0}/src/lql/commands/tui.py +0 -0
  32. {lql_cli-0.6.0 → lql_cli-0.8.0}/src/lql/commands/update.py +0 -0
  33. {lql_cli-0.6.0 → lql_cli-0.8.0}/src/lql/config.py +0 -0
  34. {lql_cli-0.6.0 → lql_cli-0.8.0}/src/lql/sessions.py +0 -0
  35. {lql_cli-0.6.0 → lql_cli-0.8.0}/src/lql/util.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lql-cli
3
- Version: 0.6.0
3
+ Version: 0.8.0
4
4
  Summary: lql — CLI for the Liquid DataViewer platform
5
5
  Project-URL: Homepage, https://github.com/Liquid4All/lql
6
6
  Author: Liquid AI
@@ -166,11 +166,23 @@ lql preview <file.jsonl|file.json> Local file: each line/object is a row
166
166
  lql preview <dataset-id> Platform dataset (fetched & paged lazily)
167
167
  lql preview <org/name> --hf HuggingFace repo: sync to DataViewer, then view
168
168
  lql preview <src> -c <field> Force field(s) as conversations (repeatable)
169
+ lql preview <src> -f "col=value" Filter rows (repeatable, AND); local & platform
169
170
  lql preview <src> -n <N> Page size when paging a platform dataset
170
171
  lql preview <src> --offset N Start at row index N
171
172
  lql preview <src> --title "<title>" Title shown in the viewer header
172
173
  ```
173
174
 
175
+ **Filtering (`--filter`/`-f`).** Show only matching rows — works on local files and
176
+ platform datasets (platform filtering runs server-side). Repeatable; filters AND
177
+ together; string match is case-insensitive. Operators: `=`, `!=`, `~` (contains),
178
+ `>`, `<`, `>=`, `<=`.
179
+
180
+ ```
181
+ lql preview <dataset-id> -f "domain=telecom"
182
+ lql preview data.jsonl -f "reward>=0.8" -f "split=test" # both must hold
183
+ lql preview <dataset-id> -f "model~lfm" # contains
184
+ ```
185
+
174
186
  **HuggingFace datasets (`--hf`).** `lql preview org/name --hf` syncs the repo
175
187
  into a DataViewer workspace, then opens it. You pick the target workspace from
176
188
  an interactive list (or pass `--workspace <id>`; `--split` defaults to `train`).
@@ -150,11 +150,23 @@ lql preview <file.jsonl|file.json> Local file: each line/object is a row
150
150
  lql preview <dataset-id> Platform dataset (fetched & paged lazily)
151
151
  lql preview <org/name> --hf HuggingFace repo: sync to DataViewer, then view
152
152
  lql preview <src> -c <field> Force field(s) as conversations (repeatable)
153
+ lql preview <src> -f "col=value" Filter rows (repeatable, AND); local & platform
153
154
  lql preview <src> -n <N> Page size when paging a platform dataset
154
155
  lql preview <src> --offset N Start at row index N
155
156
  lql preview <src> --title "<title>" Title shown in the viewer header
156
157
  ```
157
158
 
159
+ **Filtering (`--filter`/`-f`).** Show only matching rows — works on local files and
160
+ platform datasets (platform filtering runs server-side). Repeatable; filters AND
161
+ together; string match is case-insensitive. Operators: `=`, `!=`, `~` (contains),
162
+ `>`, `<`, `>=`, `<=`.
163
+
164
+ ```
165
+ lql preview <dataset-id> -f "domain=telecom"
166
+ lql preview data.jsonl -f "reward>=0.8" -f "split=test" # both must hold
167
+ lql preview <dataset-id> -f "model~lfm" # contains
168
+ ```
169
+
158
170
  **HuggingFace datasets (`--hf`).** `lql preview org/name --hf` syncs the repo
159
171
  into a DataViewer workspace, then opens it. You pick the target workspace from
160
172
  an interactive list (or pass `--workspace <id>`; `--split` defaults to `train`).
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "lql-cli"
7
- version = "0.6.0"
7
+ version = "0.8.0"
8
8
  description = "lql — CLI for the Liquid DataViewer platform"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.12"
@@ -0,0 +1 @@
1
+ __version__ = "0.7.0"
@@ -10,7 +10,7 @@ from .._group import AliasGroup
10
10
 
11
11
  from .._opts import ApiUrlOpt, JsonOpt, ProfileOpt
12
12
  from ..api import ApiClient
13
- from ..output import print_error, print_json, print_table
13
+ from ..output import print_error, print_grouped_tables, print_json, print_table
14
14
  from ..util import q
15
15
 
16
16
  app = typer.Typer(help="Manage datasets", cls=AliasGroup, no_args_is_help=True)
@@ -21,6 +21,23 @@ def _truncate(v: object, n: int = 80) -> str:
21
21
  return s[: n - 3] + "..." if len(s) > n else s
22
22
 
23
23
 
24
+ def _workspace_index(client: ApiClient) -> dict:
25
+ """Map workspace id -> {"name", "team_name"}. Returns {} if lookups fail."""
26
+ try:
27
+ teams = {t.get("id"): (t.get("name") or "") for t in client.get("/v1/teams").json()}
28
+ except Exception:
29
+ teams = {}
30
+ try:
31
+ out = {}
32
+ for w in client.get("/v1/workspaces").json():
33
+ name = w.get("display_name") or w.get("name") or ""
34
+ tname = teams.get(w.get("team_id")) or ("(unknown team)" if w.get("team_id") else "Personal")
35
+ out[w.get("id")] = {"name": name, "team_name": tname}
36
+ return out
37
+ except Exception:
38
+ return {}
39
+
40
+
24
41
  @app.command("list")
25
42
  def list_datasets(
26
43
  workspace: Annotated[Optional[str], typer.Option("--workspace", help="Filter by workspace ID")] = None,
@@ -28,6 +45,10 @@ def list_datasets(
28
45
  Optional[str],
29
46
  typer.Option("--search", "-s", help="Filter by case-insensitive substring of name / HF repo"),
30
47
  ] = None,
48
+ flat: Annotated[
49
+ bool,
50
+ typer.Option("--flat", "-f", help="Flat table instead of the default workspace-grouped view"),
51
+ ] = False,
31
52
  json_out: JsonOpt = False,
32
53
  profile: ProfileOpt = None,
33
54
  api_url: ApiUrlOpt = None,
@@ -47,13 +68,61 @@ def list_datasets(
47
68
  or s in (d.get("name") or "").lower()
48
69
  or s in (d.get("hf_repo_id") or "").lower()
49
70
  ]
71
+
72
+ ws_index = _workspace_index(client)
73
+
74
+ def _ws_info(d: dict) -> dict:
75
+ return ws_index.get(d.get("workspace_id")) or {}
76
+
77
+ # Enrich each dataset with resolved names (additive, keeps --json useful).
78
+ for d in items:
79
+ info = _ws_info(d)
80
+ d["workspace_name"] = info.get("name") or ""
81
+ d["team_name"] = info.get("team_name") or ""
82
+
83
+ if json_out:
84
+ print_json(items)
85
+ return
86
+
87
+ def _row(d: dict) -> list:
88
+ return [
89
+ d.get("id") or "",
90
+ d.get("display_name") or d.get("name") or "",
91
+ d.get("row_count") if d.get("row_count") is not None else "",
92
+ ]
93
+
94
+ def _ws_label(d: dict) -> str:
95
+ info = _ws_info(d)
96
+ wid = d.get("workspace_id") or ""
97
+ name = info.get("name") or "(unknown workspace)"
98
+ team = info.get("team_name") or ""
99
+ head = f"{name} · {team}" if team else name
100
+ return f"{head} ({wid})" if wid else head
101
+
102
+ if not flat:
103
+ groups: dict[str, list] = {}
104
+ labels: dict[str, str] = {}
105
+ for d in sorted(items, key=lambda d: (d.get("display_name") or d.get("name") or "").lower()):
106
+ info = _ws_info(d)
107
+ # Sort sections by team, then workspace name, so a team's datasets cluster.
108
+ key = (info.get("team_name") or "~", info.get("name") or "~", d.get("workspace_id") or "")
109
+ groups.setdefault(key, []).append(d)
110
+ labels[key] = _ws_label(d)
111
+ order = sorted(groups)
112
+ print_grouped_tables(
113
+ ["ID", "Name", "Rows"],
114
+ [(labels[k], [_row(d) for d in groups[k]]) for k in order],
115
+ )
116
+ return
117
+
50
118
  print_table(
51
- ["ID", "Name", "Workspace", "Rows"],
119
+ ["ID", "Name", "Workspace", "Team", "Rows"],
52
120
  [
53
121
  [
54
122
  d.get("id") or "",
55
123
  d.get("display_name") or d.get("name") or "",
56
- d.get("workspace_id") or "",
124
+ d.get("workspace_name") or d.get("workspace_id") or "",
125
+ d.get("team_name") or "",
57
126
  d.get("row_count") if d.get("row_count") is not None else "",
58
127
  ]
59
128
  for d in items
@@ -38,6 +38,13 @@ All commands accept --json for stable JSON output to stdout.
38
38
  Errors always go to stderr as: { "error": "message", "code": "slug" }
39
39
  Data always goes to stdout.
40
40
 
41
+ AGENTS: always pass --json and parse that. It is the stable contract — a flat,
42
+ unnested structure carrying full IDs plus resolved names (e.g. team_name,
43
+ workspace_name, parent_name on list results). The default human output is
44
+ Rich-rendered tables that may be grouped into sections and have truncated cells
45
+ (IDs shown as `8b45ab54-…`); do NOT parse it. Table-only display flags like
46
+ --flat / --group never affect --json output.
47
+
41
48
  Exit codes:
42
49
  0 success
43
50
  1 usage / validation error
@@ -52,7 +59,7 @@ Pagination: --limit N --offset N on list commands.
52
59
 
53
60
  A workspace is the top-level container for datasets, spec docs, and members.
54
61
 
55
- lql workspaces list [--search <text>] # --search filters by name / slug substring
62
+ lql workspaces list [--search <text>] [--flat] # grouped by team by default; --flat for a single table; --search filters by name / slug substring
56
63
  lql workspaces create <name>
57
64
  lql workspaces show <id>
58
65
  lql workspaces update <id> --name <new-name>
@@ -63,7 +70,7 @@ A workspace is the top-level container for datasets, spec docs, and members.
63
70
 
64
71
  ## Datasets
65
72
 
66
- lql datasets list [--workspace <id>] [--search <text>] # --search filters by name / HF repo substring
73
+ lql datasets list [--workspace <id>] [--search <text>] [--flat] # grouped by workspace (with team) by default; --flat for a single table; --search filters by name / HF repo substring
67
74
  lql datasets show <id>
68
75
  lql datasets create --workspace <id> --hf-repo <org/repo> [--name <display>] [--split <split>]
69
76
  lql datasets create --workspace <id> --hf-bucket <org/bucket> --key <path-or-glob> [--name <display>]
@@ -100,8 +107,17 @@ DataViewer workspace (you pick one interactively, or pass --workspace <id>;
100
107
  --split defaults to train) and reused on later previews (dedup by repo+split).
101
108
 
102
109
  Options: -c/--column (field(s) to treat as conversations; default auto-detect,
103
- repeatable), -n/--limit (page size when paging a platform dataset), --offset
104
- (start row index), --title, --hf, --split, --workspace, --profile, --api-url.
110
+ repeatable), -f/--filter (filter rows; see below), -n/--limit (page size when
111
+ paging a platform dataset), --offset (start row index), --title, --hf, --split,
112
+ --workspace, --profile, --api-url.
113
+
114
+ Filtering: -f/--filter "col<op>value" shows only matching rows — works on local
115
+ files and platform datasets (server-side for platform). Repeatable; filters AND
116
+ together; string compare is case-insensitive. Operators: = (eq), != (ne),
117
+ ~ (contains), >, <, >=, <=.
118
+
119
+ lql preview <dataset-id> -f "domain=telecom" -f "reward>=0.8"
120
+ lql preview data.jsonl -f "model~lfm"
105
121
 
106
122
  Navigation: two modes toggled with m — pager (one sample at a time; ←/→ or
107
123
  n/b switch samples, ↑/↓/j/k scroll) and scroll (all samples; n/b jump between
@@ -759,6 +759,67 @@ def _choose_workspace(client: ApiClient, tui_mod) -> Optional[str]:
759
759
  return choice
760
760
 
761
761
 
762
+ # --------------------------------------------------------------------------
763
+ # Row filtering (--filter "col<op>value")
764
+ # --------------------------------------------------------------------------
765
+
766
+ # Maps each CLI symbol to the platform filter API's operator name (the same
767
+ # names work server-side and locally). _parse_filters picks the earliest operator
768
+ # (longest on a tie), so list order doesn't affect correctness.
769
+ _FILTER_OPS = [(">=", "gte"), ("<=", "lte"), ("!=", "ne"), ("~", "contains"), ("=", "eq"), (">", "gt"), ("<", "lt")]
770
+ _NUMERIC_OPS = {"gt": lambda c, v: c > v, "lt": lambda c, v: c < v, "gte": lambda c, v: c >= v, "lte": lambda c, v: c <= v}
771
+
772
+
773
+ def _parse_filters(specs: Optional[List[str]]) -> List[tuple]:
774
+ """Parse ['col=value', 'reward>=0.5', 'name~kod'] → [(col, op, value), ...].
775
+
776
+ Splits on the EARLIEST operator (longest on a tie, so 'reward>=5' is gte not
777
+ gt), keeping operator chars in the value intact (e.g. 'q=a>b' → col 'q', value
778
+ 'a>b'). Rejects an empty column or value."""
779
+ out: List[tuple] = []
780
+ for spec in specs or []:
781
+ chosen = None # (index, symbol, op_name)
782
+ for sym, op in _FILTER_OPS:
783
+ i = spec.find(sym)
784
+ if i > 0 and (chosen is None or i < chosen[0] or (i == chosen[0] and len(sym) > len(chosen[1]))):
785
+ chosen = (i, sym, op)
786
+ if chosen is None:
787
+ print_error(
788
+ f"Invalid --filter '{spec}'. Use col=value, col!=value, col~text, or col>/</>=/<= N.",
789
+ "bad_filter",
790
+ )
791
+ raise typer.Exit(1)
792
+ i, sym, op = chosen
793
+ col, val = spec[:i].strip(), spec[i + len(sym):].strip()
794
+ if not col or not val:
795
+ print_error(f"Invalid --filter '{spec}': both a column and a value are required.", "bad_filter")
796
+ raise typer.Exit(1)
797
+ out.append((col, op, val))
798
+ return out
799
+
800
+
801
+ def _cell_matches(cell: object, op: str, val: str) -> bool:
802
+ if op == "contains":
803
+ return cell is not None and val.lower() in str(cell).lower()
804
+ if op in ("eq", "ne"):
805
+ equal = cell is not None and str(cell).strip().lower() == val.strip().lower()
806
+ return equal if op == "eq" else not equal
807
+ try:
808
+ return _NUMERIC_OPS[op](float(cell), float(val)) # gt/lt/gte/lte
809
+ except (TypeError, ValueError):
810
+ return False
811
+
812
+
813
+ def _row_matches(row: object, filters: List[tuple]) -> bool:
814
+ """Client-side predicate (local files). A non-dict row can't match a column
815
+ filter. All filters AND together."""
816
+ if not filters:
817
+ return True
818
+ if not isinstance(row, dict):
819
+ return False
820
+ return all(_cell_matches(row.get(col), op, val) for col, op, val in filters)
821
+
822
+
762
823
  # --------------------------------------------------------------------------
763
824
  # Command
764
825
  # --------------------------------------------------------------------------
@@ -772,6 +833,13 @@ def preview(
772
833
  ] = None,
773
834
  limit: Annotated[int, typer.Option("--limit", "-n", help="Page size when paging a platform dataset")] = 25,
774
835
  offset: Annotated[int, typer.Option("--offset", help="Start at this row index")] = 0,
836
+ filter_: Annotated[
837
+ Optional[List[str]],
838
+ typer.Option(
839
+ "--filter", "-f",
840
+ help="Filter rows: 'col=value', 'col!=value', 'col~text' (contains), or 'col>/</>=/<= N'. Repeatable (AND).",
841
+ ),
842
+ ] = None,
775
843
  title: Annotated[Optional[str], typer.Option("--title", help="Title shown in the viewer header")] = None,
776
844
  hf: Annotated[
777
845
  bool, typer.Option("--hf", help="Treat SOURCE as a HuggingFace repo (org/name); sync it to DataViewer, then view")
@@ -801,13 +869,20 @@ def preview(
801
869
  print_error("The terminal viewer requires 'textual'. Install it: pip install textual", "missing_textual")
802
870
  raise typer.Exit(1)
803
871
 
872
+ filters = _parse_filters(filter_)
804
873
  local_path = Path(source)
805
874
  is_local = (not hf) and local_path.exists() and local_path.is_file()
806
875
 
807
- # Local file → load whole, view immediately.
876
+ # Local file → load whole (filter client-side), view immediately.
808
877
  if is_local:
878
+ rows = _load_local(local_path)
879
+ if filters:
880
+ rows = [r for r in rows if _row_matches(r, filters)]
881
+ if not rows:
882
+ print_error("No rows match the filter(s).", "no_match")
883
+ raise typer.Exit(3)
809
884
  tui_mod.run(
810
- tui_mod.RowSource(initial=_load_local(local_path)),
885
+ tui_mod.RowSource(initial=rows),
811
886
  title or local_path.name,
812
887
  forced_cols=column or None,
813
888
  start_idx=max(0, offset),
@@ -834,11 +909,17 @@ def preview(
834
909
  view_title = title or f"dataset {source}"
835
910
 
836
911
  page_size = limit if limit and limit > 0 else 25
912
+ api_filters = [{"column": col, "operator": op, "value": val} for col, op, val in filters]
837
913
 
838
914
  def _fetch_page(off: int, lim: int) -> List[object]:
839
- data = client.get(
840
- f"/v1/datasets/{q(dataset_id)}/rows", params={"limit": str(lim), "offset": str(offset + off)}
841
- ).json()
915
+ params = {"limit": str(lim), "offset": str(offset + off)}
916
+ if api_filters:
917
+ # Server-side filtering via the same endpoint `eval samples` uses.
918
+ data = client.post(
919
+ f"/v1/datasets/{q(dataset_id)}/rows/filter", json={"filters": api_filters}, params=params
920
+ ).json()
921
+ else:
922
+ data = client.get(f"/v1/datasets/{q(dataset_id)}/rows", params=params).json()
842
923
  return _normalize_loaded(data)
843
924
 
844
925
  # Fetch the first page up front (with feedback) so the viewer opens already
@@ -857,8 +938,13 @@ def preview(
857
938
  if first:
858
939
  break
859
940
  if not first:
860
- msg = f"Dataset returned no rows for split '{split}'." if hf else "Dataset returned no rows."
861
- print_error(msg, "empty_dataset")
941
+ if filters:
942
+ msg, code = "No rows match the filter(s).", "no_match"
943
+ elif hf:
944
+ msg, code = f"Dataset returned no rows for split '{split}'.", "empty_dataset"
945
+ else:
946
+ msg, code = "Dataset returned no rows.", "empty_dataset"
947
+ print_error(msg, code)
862
948
  raise typer.Exit(3)
863
949
  row_source = tui_mod.RowSource(initial=first, fetch_page=_fetch_page, page_size=page_size)
864
950
  tui_mod.run(
@@ -7,7 +7,7 @@ from .._group import AliasGroup
7
7
 
8
8
  from .._opts import ApiUrlOpt, JsonOpt, ProfileOpt
9
9
  from ..api import ApiClient
10
- from ..output import print_json, print_table
10
+ from ..output import print_grouped_tables, print_json, print_table
11
11
  from ..util import q
12
12
 
13
13
  app = typer.Typer(help="Manage workspaces", cls=AliasGroup, no_args_is_help=True)
@@ -15,12 +15,28 @@ members_app = typer.Typer(help="Manage workspace members", cls=AliasGroup, no_ar
15
15
  app.add_typer(members_app, name="members")
16
16
 
17
17
 
18
+ def _ws_name(w: dict) -> str:
19
+ return w.get("display_name") or w.get("name") or ""
20
+
21
+
22
+ def _team_names(client: ApiClient) -> dict:
23
+ """Map team id -> team name. Returns {} if teams can't be fetched."""
24
+ try:
25
+ return {t.get("id"): (t.get("name") or "") for t in client.get("/v1/teams").json()}
26
+ except Exception:
27
+ return {}
28
+
29
+
18
30
  @app.command("list")
19
31
  def list_workspaces(
20
32
  search: Annotated[
21
33
  Optional[str],
22
34
  typer.Option("--search", "-s", help="Filter by case-insensitive substring of name / slug"),
23
35
  ] = None,
36
+ flat: Annotated[
37
+ bool,
38
+ typer.Option("--flat", "-f", help="Flat table instead of the default team-grouped view"),
39
+ ] = False,
24
40
  json_out: JsonOpt = False,
25
41
  profile: ProfileOpt = None,
26
42
  api_url: ApiUrlOpt = None,
@@ -37,9 +53,54 @@ def list_workspaces(
37
53
  or s in (w.get("name") or "").lower()
38
54
  or s in (w.get("slug") or "").lower()
39
55
  ]
56
+
57
+ team_names = _team_names(client)
58
+ name_by_id = {w.get("id"): _ws_name(w) for w in items}
59
+ # Enrich each workspace with resolved names (additive, keeps --json useful).
60
+ for w in items:
61
+ w["team_name"] = team_names.get(w.get("team_id")) or ""
62
+ w["parent_name"] = name_by_id.get(w.get("parent_id")) or ""
63
+
64
+ if json_out:
65
+ print_json(items)
66
+ return
67
+
68
+ def _team_label(w: dict) -> str:
69
+ name, tid = w.get("team_name"), w.get("team_id")
70
+ if name:
71
+ return f"{name} ({tid})"
72
+ if tid:
73
+ return f"(unknown team) ({tid})"
74
+ return "Personal"
75
+
76
+ if not flat:
77
+ groups: dict[str, list] = {}
78
+ for w in sorted(items, key=lambda w: _ws_name(w).lower()):
79
+ groups.setdefault(_team_label(w), []).append(w)
80
+ # Named teams first (alphabetical), then unknown teams, then Personal last.
81
+ def _rank(label: str) -> tuple:
82
+ if label == "Personal":
83
+ return (2, label)
84
+ if label.startswith("(unknown team)"):
85
+ return (1, label)
86
+ return (0, label.lower())
87
+
88
+ order = sorted(groups, key=_rank)
89
+ print_grouped_tables(
90
+ ["ID", "Name", "Parent"],
91
+ [
92
+ (label, [[w.get("id") or "", _ws_name(w), w.get("parent_name") or ""] for w in groups[label]])
93
+ for label in order
94
+ ],
95
+ )
96
+ return
97
+
40
98
  print_table(
41
- ["ID", "Name"],
42
- [[w.get("id") or "", w.get("display_name") or w.get("name") or ""] for w in items],
99
+ ["ID", "Name", "Parent", "Team"],
100
+ [
101
+ [w.get("id") or "", _ws_name(w), w.get("parent_name") or "", _team_label(w)]
102
+ for w in items
103
+ ],
43
104
  json_out,
44
105
  items,
45
106
  )
@@ -1,6 +1,6 @@
1
1
  import json
2
2
  import sys
3
- from typing import List, Sequence
3
+ from typing import List, Sequence, Tuple
4
4
 
5
5
  from rich.console import Console
6
6
  from rich.table import Table
@@ -24,6 +24,26 @@ def print_table(headers: Sequence[str], rows: Sequence[Sequence[str]], is_json:
24
24
  _console.print(table)
25
25
 
26
26
 
27
+ def print_grouped_tables(
28
+ headers: Sequence[str],
29
+ groups: Sequence[Tuple[str, Sequence[Sequence[str]]]],
30
+ ) -> None:
31
+ """Print one table per group, each preceded by a bold group header.
32
+
33
+ `groups` is a sequence of (label, rows) pairs. JSON callers should print
34
+ the underlying data directly rather than going through this helper.
35
+ """
36
+ for label, rows in groups:
37
+ _console.print(f"[bold]{label}[/bold] ({len(rows)})")
38
+ table = Table(show_header=True, header_style="bold")
39
+ for h in headers:
40
+ table.add_column(str(h))
41
+ for row in rows:
42
+ table.add_row(*[str(c) for c in row])
43
+ _console.print(table)
44
+ _console.print()
45
+
46
+
27
47
  def print_error(message: str, code: str) -> None:
28
48
  # Compact, machine-readable; always to stderr (matches the TS contract).
29
49
  sys.stderr.write(json.dumps({"error": message, "code": code}) + "\n")
@@ -185,7 +185,7 @@ wheels = [
185
185
 
186
186
  [[package]]
187
187
  name = "lql-cli"
188
- version = "0.6.0"
188
+ version = "0.7.0"
189
189
  source = { editable = "." }
190
190
  dependencies = [
191
191
  { name = "httpx" },
@@ -1 +0,0 @@
1
- __version__ = "0.6.0"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes