Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,24 @@ your local browser.
the output shape. One-shot commands emit a single JSON object on stdout;
errors and warnings are single JSON objects on stderr.

### Field projection (`-o`)

The list/account read commands — `assembly transcripts list`, `assembly
sessions list`/`get`, `assembly balance`, `assembly usage`, `assembly limits`,
`assembly keys list`, and `assembly audit` — also accept `-o FIELDS` to project
columns straight out of the JSON, so a "grab one column" pipeline needs no
external `jq`. Pass a single field (`-o id`) or a comma-separated list (`-o
id,status`); dotted paths (`-o transform.model`) reach nested objects. A list
result prints one tab-separated line per row, a single record one line; a
missing field (or `null`) is an empty column, and a nested object/list is
re-serialized as compact JSON. `-o` takes precedence over `--json`.

```sh
assembly transcripts list -o id | head -1 # newest transcript id
assembly keys list -o id,name # id<TAB>name per key
assembly balance -o balance_in_cents # the raw integer
```

Streaming commands emit newline-delimited JSON (NDJSON), one event per line,
each carrying a `"type"` field to dispatch on:

Expand Down
10 changes: 7 additions & 3 deletions aai_cli/commands/account.py
Original file line number Diff line number Diff line change
Expand Up @@ -141,13 +141,14 @@ class _Usage(BaseModel):
epilog=examples_epilog(
[
("Show your remaining balance", "assembly balance"),
("Get the raw cents for scripting", "assembly balance --json | jq '.balance_in_cents'"),
("Get the raw cents for scripting", "assembly balance -o balance_in_cents"),
]
),
)
def balance(
ctx: typer.Context,
json_out: bool = options.json_option(),
fields: str | None = options.fields_option(),
) -> None:
"""Show your remaining account balance"""

Expand All @@ -159,6 +160,7 @@ def body(state: AppState, json_mode: bool) -> None:
data,
lambda _d: f"Balance: [aai.success]${cents / 100:,.2f}[/aai.success]",
json_mode=json_mode,
fields=fields,
)

run_command(ctx, body, json=json_out)
Expand Down Expand Up @@ -194,6 +196,7 @@ def usage(
help="Include zero-usage windows (matches --include-logins on `assembly audit`)",
),
json_out: bool = options.json_option(),
fields: str | None = options.fields_option(),
) -> None:
"""Show usage over a date range (default: last 30 days)"""

Expand Down Expand Up @@ -254,7 +257,7 @@ def render(d: dict[str, object]) -> object:
hidden_note = output.hidden_note(hidden_count, "zero-usage window", "--include-zero")
return output.stack(summary, table, hidden_note)

output.emit(data, render, json_mode=json_mode)
output.emit(data, render, json_mode=json_mode, fields=fields)

run_command(ctx, body, json=json_out)

Expand All @@ -271,6 +274,7 @@ def render(d: dict[str, object]) -> object:
def limits(
ctx: typer.Context,
json_out: bool = options.json_option(),
fields: str | None = options.fields_option(),
) -> None:
"""Show your account's rate limits per service"""

Expand All @@ -292,6 +296,6 @@ def render(d: dict[str, object]) -> object:
)
return table

output.emit(data, render, json_mode=json_mode)
output.emit(data, render, json_mode=json_mode, fields=fields)

run_command(ctx, body, json=json_out)
4 changes: 3 additions & 1 deletion aai_cli/commands/audit.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ def _audit_rows(payload: Mapping[str, object]) -> list[dict[str, object]]:
("Include login events", "assembly audit --include-logins"),
("Filter by action", "assembly audit --action token.create"),
("Filter by resource, as JSON", "assembly audit --resource token --json"),
("Pull action and actor as columns", "assembly audit -o action_taken,actor_id"),
]
),
)
Expand All @@ -102,6 +103,7 @@ def audit(
False, "--include-logins", help="Show successful login events"
),
json_out: bool = options.json_option(),
fields: str | None = options.fields_option(),
) -> None:
"""List recent audit-log entries for your account"""

Expand Down Expand Up @@ -133,6 +135,6 @@ def render(data: list[dict[str, object]]) -> object:
)
return output.stack(table, hidden_note)

output.emit(rows, render, json_mode=json_mode)
output.emit(rows, render, json_mode=json_mode, fields=fields)

run_command(ctx, body, json=json_out)
5 changes: 3 additions & 2 deletions aai_cli/commands/keys.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,13 +58,14 @@ def _default_project_id(account_id: int, jwt: str) -> int:
[
("List your API keys (masked)", "assembly keys list"),
("As JSON for scripting", "assembly keys list --json"),
("Get key ids to use with rename", "assembly keys list --json | jq '.[].id'"),
("Get key ids to use with rename", "assembly keys list -o id"),
]
),
)
def list_(
ctx: typer.Context,
json_out: bool = options.json_option(),
fields: str | None = options.fields_option(),
) -> None:
"""List API keys across your projects (shown masked)"""

Expand Down Expand Up @@ -100,7 +101,7 @@ def render(data: list[dict[str, object]]) -> object:
)
return table

output.emit(rows, render, json_mode=json_mode)
output.emit(rows, render, json_mode=json_mode, fields=fields)

run_command(ctx, body, json=json_out)

Expand Down
15 changes: 9 additions & 6 deletions aai_cli/commands/sessions.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,11 +59,11 @@ class SessionStatus(enum.StrEnum):
("Find failed sessions", "assembly sessions list --status error"),
(
"Inspect the most recent session",
"assembly sessions get $(assembly sessions list --json | jq -r '.[0].session_id')",
"assembly sessions get $(assembly sessions list -o session_id | head -1)",
),
(
"Total audio across recent sessions (seconds)",
"assembly sessions list --json | jq '[.[].audio_duration_sec] | add'",
"Pull session ids and durations as columns",
"assembly sessions list -o session_id,audio_duration_sec",
),
]
),
Expand All @@ -75,6 +75,7 @@ def list_(
None, "--status", help="Only show sessions with this status"
),
json_out: bool = options.json_option(),
fields: str | None = options.fields_option(),
) -> None:
"""List recent streaming sessions"""

Expand Down Expand Up @@ -108,7 +109,7 @@ def render(data: list[dict[str, object]]) -> object:
)
return table

output.emit(rows, render, json_mode=json_mode)
output.emit(rows, render, json_mode=json_mode, fields=fields)

run_command(ctx, body, json=json_out)

Expand All @@ -118,9 +119,10 @@ def render(data: list[dict[str, object]]) -> object:
[
("Show one session's details", "assembly sessions get sess_5551234"),
("Raw JSON for one session", "assembly sessions get sess_5551234 --json"),
("Grab one field", "assembly sessions get sess_5551234 -o audio_duration_sec"),
(
"Drill into the latest session",
"assembly sessions get $(assembly sessions list --json | jq -r '.[0].session_id')",
"assembly sessions get $(assembly sessions list -o session_id | head -1)",
),
]
)
Expand All @@ -129,6 +131,7 @@ def get(
ctx: typer.Context,
session_id: str = typer.Argument(..., help="Streaming session id"),
json_out: bool = options.json_option(),
fields: str | None = options.fields_option(),
) -> None:
"""Show details for one streaming session"""

Expand All @@ -144,6 +147,6 @@ def render(d: dict[str, object]) -> Table:
table.add_row(label, escape("" if value is None else str(value)))
return table

output.emit(data, render, json_mode=json_mode)
output.emit(data, render, json_mode=json_mode, fields=fields)

run_command(ctx, body, json=json_out)
7 changes: 4 additions & 3 deletions aai_cli/commands/transcripts.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,11 @@
[
("List your recent transcripts", "assembly transcripts list"),
("Show more at once", "assembly transcripts list --limit 50"),
("Grab the latest transcript id", "assembly transcripts list --json | jq -r '.[0].id'"),
("Grab the latest transcript id", "assembly transcripts list -o id | head -1"),
(
"Summarize your latest transcript",
'assembly llm "summarize" --transcript-id '
"$(assembly transcripts list --json | jq -r '.[0].id')",
"$(assembly transcripts list -o id | head -1)",
),
]
),
Expand All @@ -42,6 +42,7 @@ def list_(
ctx: typer.Context,
limit: int = typer.Option(10, "--limit", help="How many transcripts to show", min=1),
json_out: bool = options.json_option(),
fields: str | None = options.fields_option(),
) -> None:
"""List recent transcripts"""

Expand All @@ -61,7 +62,7 @@ def render(data: list[dict[str, object]]) -> object:
)
return table

output.emit(rows, render, json_mode=json_mode)
output.emit(rows, render, json_mode=json_mode, fields=fields)

run_command(ctx, body, json=json_out)

Expand Down
81 changes: 81 additions & 0 deletions aai_cli/core/project.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
from __future__ import annotations

from aai_cli.core import jsonshape
from aai_cli.core.errors import UsageError

# Columns within one projected record are tab-separated, so a multi-field row
# (`-o id,status`) stays parseable with `cut -f` and pastes into a spreadsheet.
COLUMN_SEP = "\t"


def parse_fields(spec: str) -> list[str]:
"""Parse a comma-separated ``-o`` field spec into a list of field paths.

Whitespace around each name is trimmed and empty segments dropped, so
``-o "id, status"`` and ``-o id,status`` are equivalent. An all-empty spec
(e.g. ``-o ,``) is a usage error rather than a silently empty projection.
"""
fields = [part.strip() for part in spec.split(",")]
fields = [field for field in fields if field]
if not fields:
raise UsageError(
"No fields given to -o.",
suggestion="Pass one or more field names, e.g. -o id or -o id,status.",
)
return fields


def _lookup(record: dict[str, object], path: str) -> object:
"""Resolve a dotted ``path`` against a JSON object, or ``None`` if a step is missing.

Dotted access (``a.b``) descends into nested objects, so a top-level field like
``session_id`` and a nested one like ``transform.model`` both work; a path that
runs off a non-object (or names a missing key) yields ``None``, rendered as an
empty column rather than raising.
"""
value: object = record
for key in path.split("."):
mapping = jsonshape.as_mapping(value)
if mapping is None or key not in mapping:
return None
value = mapping[key]
return value


def render_value(value: object) -> str:
"""Render one projected value as a pipe-friendly scalar.

``None`` becomes an empty column, JSON booleans render lowercased
(``true``/``false``) so they read like the ``--json`` payload, and a nested
object/list is re-serialized as compact JSON rather than Python ``repr``.
"""
if value is None:
return ""
if isinstance(value, bool):
return "true" if value else "false"
if isinstance(value, (str, int, float)):
return str(value)
return jsonshape.dumps(value)


def project_record(record: dict[str, object], fields: list[str]) -> str:
"""One tab-separated line of the selected ``fields`` from a single JSON object."""
return COLUMN_SEP.join(render_value(_lookup(record, field)) for field in fields)


def project_rows(rows: list[dict[str, object]], fields: list[str]) -> list[str]:
"""One projected line per object in ``rows`` (a list result)."""
return [project_record(row, fields) for row in rows]


def project_any(data: object, fields: list[str]) -> list[str]:
"""Project ``fields`` from a JSON value, dispatching on its shape.

A single object yields one line; a list yields one line per object (non-object
items drop out); anything else (a bare scalar) yields no lines. Lets the output
layer stay shape-agnostic — it just prints whatever lines come back.
"""
mapping = jsonshape.as_mapping(data)
if mapping is not None:
return [project_record(mapping, fields)]
return project_rows(jsonshape.mapping_list(data), fields)
18 changes: 18 additions & 0 deletions aai_cli/options.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,24 @@ def json_option(help_text: str = "Output raw JSON") -> bool:
return flag


def fields_option() -> str | None:
"""The ``-o/--output`` field projection shared by the list/account read commands.

Lets ``assembly transcripts list -o id`` or ``assembly sessions list -o
session_id,status`` replace a ``--json | jq`` column grab: it projects the named
fields out of the same JSON the command would emit, one tab-separated line per
record. Comma-separated for multiple fields; dotted paths reach nested objects.
"""
value: str | None = typer.Option(
None,
"-o",
"--output",
help="Project fields from the JSON result (comma-separated, e.g. id,status)",
metavar="FIELDS",
)
return value


def chars_per_caption_option() -> int | None:
"""The ``--chars-per-caption`` knob for the ``-o srt``/``-o vtt`` subtitle exports."""
value: int | None = typer.Option(
Expand Down
33 changes: 30 additions & 3 deletions aai_cli/ui/output.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
from rich.text import Text

from aai_cli import __version__
from aai_cli.core import choices, env, jsonshape, stdio
from aai_cli.core import choices, env, jsonshape, project, stdio
from aai_cli.ui import theme

if TYPE_CHECKING:
Expand Down Expand Up @@ -196,13 +196,40 @@ def stack(*items: RenderableType | None) -> RenderableType:
return present[0] if len(present) == 1 else Group(*present)


def emit[T](data: T, human_renderer: Callable[[T], object], *, json_mode: bool) -> None:
if json_mode:
def emit[T](
data: T,
human_renderer: Callable[[T], object],
*,
json_mode: bool,
fields: str | None = None,
) -> None:
"""Emit ``data`` in one of three shapes: projected fields, raw JSON, or human render.

``fields`` (a comma-separated ``-o`` spec) wins when set, so a read command's
``-o id,status`` projection takes precedence over ``--json`` and prints
pipe-friendly columns; otherwise ``json_mode`` selects the raw JSON dump, and
the default is the command's human renderer.
"""
if fields is not None:
emit_fields(data, project.parse_fields(fields))
elif json_mode:
print(jsonshape.dumps(data))
else:
console.print(human_renderer(data))


def emit_fields(data: object, fields: list[str]) -> None:
"""Project ``fields`` from JSON ``data`` to stdout — one tab-separated line per
list item, or a single line for one record.

Backs the ``-o id,status`` projection that lets read commands drop the external
``jq`` from a 'grab a column' pipeline. A list result yields one line per row; a
single object yields one line.
"""
for line in project.project_any(data, fields):
print(line)


def emit_ndjson(obj: object) -> None:
"""Write one newline-delimited JSON record to stdout, flushed for live pipelines."""
print(jsonshape.dumps(obj), flush=True)
Expand Down
Loading
Loading