Skip to content

Commit 5d08c6d

Browse files
alexkromanclaude
andauthored
Add assembly code terminal coding agent backed by LLM Gateway (#230)
Introduces a new `assembly code` command that runs an autonomous coding agent in the terminal, built on the deepagents SDK and wired to only communicate with the AssemblyAI LLM Gateway. ## Summary This PR adds a complete coding agent feature to the CLI, enabling users to run an interactive agent that can read/write files, execute shell commands, search documentation, and invoke the `assembly` CLI itself — all within a working directory. The agent is available through two interfaces: a rich Textual TUI (primary) and a plain Rich REPL (fallback for headless/piped runs). ## Key Changes **Core Agent Infrastructure** (`aai_cli/code_agent/`) - `agent.py`: Assembles the deepagents graph with the gateway model, filesystem/shell tools, custom CLI tool, and human-in-the-loop approval gating on mutating operations - `session.py`: Framework-agnostic turn orchestration — drives the agent, resolves approval interrupts, and emits display events to injected sinks - `events.py`: Converts langchain messages to a small vocabulary of display events (AssistantText, ToolCall, ToolResult, ErrorText) consumed by both front-ends - `model.py`: Builds the chat model (always AssemblyAI LLM Gateway via langchain_openai), with content-flattening to work around gateway limitations - `prompt.py`: System prompt template and model defaults (Claude Sonnet 4.6, 8K max tokens) **Tools & Integrations** - `cli_tool.py`: Exposes the `assembly` CLI as a tool; runs subcommands in a subprocess with API key injected via environment (never argv) to prevent secret leakage - `fetch_tool.py`: URL-fetch tool (approval-gated for SSRF protection) - `ask_tool.py`: Allows the agent to ask the user questions mid-task via an injected bridge (framework-agnostic) - `docs_mcp.py`: Loads AssemblyAI docs MCP server tools for documentation search - `web_search.py`: Optional Tavily web search (enabled when `TAVILY_API_KEY` is set) - `skills.py`: Imports installed agent skills (e.g., `assemblyai` skill) via a separate filesystem backend - `memory.py`: Long-term memory middleware with persistent storage across sessions - `store.py`: SQLite checkpoint persistence for resumable sessions (in-memory fallback for ephemeral runs) - `banner.py`: Startup splash with ASSEMBLY wordmark and intro copy **User Interfaces** - `tui.py`: Textual app with scrolling transcript, bottom input, and modal approval/ask screens; runs the agent on a worker thread with events streamed back to the UI thread - `render.py`: Rich console renderer for headless/piped runs and as a fallback - `session.py`: `run_repl()` function for interactive line-by-line input **Command Wiring** (`aai_cli/commands/code/`) - `__init__.py`: Command definition with all flags (--model, --dir, --auto, --docs, --skills, --web, --memory, --session, --persist, --tui) - `_exec.py`: Run logic that assembles tools, middlewares, the agent, and dispatches to TUI (if TTY) or REPL (headless) **Tests** - `tests/test_code_agent.py`: 386-line end-to-end suite exercising the real deepagents graph with a fake chat model, covering file writes, approvals, auto-approve, REPL loop, tool invocation, and middleware - `tests/test_code_tui.py`: Textual pilot tests (headless) for app composition, splash rendering, turn execution, event rendering, and approval/ask modals - `tests/test_code_command.py`: Command wiring tests for flag parsing, TTY/headless dispatch, and tool assembly ## Notable Implementation Details - **Approval gating**: Mutating tools (write_file, edit_file, execute, assembly, fetch_url) are gated behind an approver callback unless ` https://claude.ai/code/session_01Mqx2vYy9FS5Lxpf3ekBGsr --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 69c7803 commit 5d08c6d

33 files changed

Lines changed: 4102 additions & 52 deletions

.importlinter

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ type = layers
1313
; assembles the command layer — main, command_registry, help_panels, options —
1414
; stays at the package root, above `commands`, and is intentionally unlisted
1515
; (it legitimately imports the command modules to discover/register them).
16-
; Feature slices (agent, tts, streaming, code_gen, init, auth, onboard) are
16+
; Feature slices (agent, tts, streaming, code_agent, code_gen, init, auth, onboard) are
1717
; likewise unlisted vertical slices governed by contract 2.
1818
layers =
1919
commands
@@ -34,6 +34,7 @@ source_modules =
3434
aai_cli.agent
3535
aai_cli.agent_cascade
3636
aai_cli.auth
37+
aai_cli.code_agent
3738
aai_cli.code_gen
3839
aai_cli.init
3940
aai_cli.onboard

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ That's it. Run `assembly onboard` for a guided tour, or see [Installation](#-ins
5151
| `assembly agent-cascade` | Same live conversation, but wired client-side from Streaming STT + the LLM Gateway + streaming TTS, like the `agent-cascade` starter (sandbox-only) |
5252
| `assembly speak` | Synthesize text to speech over the streaming-TTS WebSocket (sandbox-only) |
5353
| `assembly llm` | Prompt the LLM Gateway over a transcript, files, stdin, or a live stream |
54+
| `assembly code` | Terminal coding agent (deepagents SDK) backed only by the LLM Gateway — reads/writes/edits files, runs shell, searches the docs MCP, and can invoke the `assembly` CLI itself; mutating actions ask for approval |
5455
| `assembly clip` | Cut audio/video with ffmpeg by diarized speaker, text match, LLM pick, or time range (`--video` keeps the picture for URL sources) — clip boundaries snap into nearby silence |
5556
| `assembly dub` | Re-voice an audio/video file or URL in another language: transcription, LLM translation, per-speaker TTS, ffmpeg track-swap (sandbox-only) |
5657
| `assembly caption` | Burn always-visible captions into a video: transcribe (or reuse a transcript), fetch SRT, ffmpeg burns it in — audio untouched |

aai_cli/AGENTS.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,8 +44,8 @@ contract:
4444
`help_panels`, `options`. They assemble/define the command layer (and
4545
`command_registry` imports the command modules to discover them), so they live
4646
*above* `commands` and stay at the root.
47-
- **Feature slices**`agent/`, `tts/`, `streaming/`, `code_gen/`, `init/`,
48-
`auth/`, `onboard/`. These are cohesive vertical slices that internally mix
47+
- **Feature slices**`agent/`, `tts/`, `streaming/`, `code_agent/`, `code_gen/`,
48+
`init/`, `auth/`, `onboard/`. These are cohesive vertical slices that internally mix
4949
protocol + rendering, so they aren't a single horizontal layer; contract 2
5050
forbids them from importing `commands`.
5151

@@ -153,6 +153,7 @@ heavily-reworked commands with long bodies; small commands keep the inline
153153
- **`agent/`** — full-duplex voice agent (mic in, TTS out via `voices.py`).
154154
- **`agent_cascade/`** + `commands/agent_cascade/``assembly agent-cascade`: the same live terminal conversation as `assembly agent`, but **client-orchestrated**`engine.run_cascade` wires Streaming STT → the LLM Gateway → streaming TTS itself instead of talking to the Voice Agent endpoint, mirroring what the `agent-cascade` `assembly init` template does server-side. **Sandbox-only** (streaming TTS has no prod host; guarded via `tts.session.require_available`). Reuses the agent slice's `DuplexAudio`/`AgentRenderer` and `core.client.stream_audio`/`core.llm.complete`/`tts.session.synthesize`; the three network legs are injected through `engine.CascadeDeps` (the `tts/session.py` seam) so the cascade — greeting, per-sentence TTS, barge-in, history window — is unit-tested against fakes with no sockets/mic/speaker.
155155
- **`tts/`** + `commands/speak.py` — `assembly speak` synthesizes text to speech over the sandbox streaming-TTS WebSocket (`streaming-tts.sandbox000.…`). **Sandbox-only:** `session.is_available()` is false in production (empty `Environment.streaming_tts_host`), so the command exits 2 with a `--sandbox` hint. `session.synthesize` drives a Begin→Generate→Flush→Audio→Terminate protocol with an injectable `connect` for hermetic tests (mirrors `agent/session.py`); `audio.py` plays the PCM (default) or writes a WAV (`--out`). The single-voice default-playback path **streams**: `synthesize`'s `on_audio(chunk, sample_rate)` callback is wired to `audio.PcmPlayer.feed`, so speech starts on the first Audio frame (it opens the device lazily, since the rate is only known at Begin) instead of after the whole text — the win for a long `--url` page. `--out` (needs the full buffer) and the multi-voice dialogue path (`synthesize_dialogue` → `_output_audio` → buffered `play_pcm`) stay buffered; `synthesize` still returns the complete PCM for the summary regardless.
156+
- **`code_agent/`** + `commands/code/` — `assembly code`: a terminal coding agent (a bespoke port of langchain-ai/deepagents' `code` agent) that talks **only** to the LLM Gateway. `model.py` pins the model to `ChatOpenAI` against `llm_gateway_base`; `agent.py` builds the deepagents graph over a cwd-scoped `LocalShellBackend` (filesystem + shell tools), plus extra tools: the custom `assembly` CLI tool (`cli_tool.py`, runs `python -m aai_cli` with the key via child env, never argv), a URL `fetch_url` tool (`fetch_tool.py`), Tavily web search when `TAVILY_API_KEY` is set (`web_search.py`), an `ask_user` tool routed through an `AskBridge` to the front-end (`ask_tool.py`), and best-effort docs MCP tools (`docs_mcp.py`). Middleware adds installed skills (`skills.py`) and long-term memory (`memory.py`), each over its own dedicated backend. Sessions persist via a SQLite checkpointer (`store.py`) keyed by `--session`, so conversations resume. Approval gates the mutating tools (write/edit/execute/`assembly`/`fetch_url`); the general-purpose `task` subagent comes from deepagents by default. `session.py` drives the graph turn-by-turn (interrupt/resume = human approval), emitting framework-agnostic `events.py` to either the Textual TUI (`tui.py`, modeled on deepagents-code: transcript + input + approval/ask modals + clipboard copy) or the Rich fallback (`render.py`). The whole orchestration is tested by driving the **real** graph with a fake `BaseChatModel` (`tests/test_code_agent.py`), so no network/TTY is needed.
156157
- **`code_gen/`** — backs `--show-code` on `transcribe`/`stream`/`agent`: builds a ready-to-run Python SDK script from exactly the flags passed (no API key needed; generated code reads `ASSEMBLYAI_API_KEY`).
157158
- **`auth/`** — browser-assisted `assembly login` via AMS + **Stytch B2B OAuth discovery** (`discovery.py`, `flow.py`, `loopback.py`, `ams.py`). Not Stytch Connected Apps.
158159
- **`init/`** — scaffolds a self-contained FastAPI + HTML starter (`audio-transcription`/`live-captions`/`voice-agent` templates), optionally installs deps and opens the browser; writes the key to a git-ignored `.env`.

aai_cli/code_agent/__init__.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
"""`assembly code` — a terminal coding agent built on the deepagents SDK.
2+
3+
A bespoke port of langchain-ai/deepagents' `code` agent, wired so it **only**
4+
talks to the AssemblyAI LLM Gateway (an OpenAI-compatible endpoint reached via
5+
`langchain_openai.ChatOpenAI`; see `model.py`). The agent gets deepagents'
6+
built-in filesystem + shell tools — rooted at the working directory through a
7+
`LocalShellBackend` — plus a custom `assembly` tool that invokes this very CLI,
8+
so it can transcribe/stream/run-LLM as part of a coding task (`cli_tool.py`).
9+
10+
The pieces are split so the orchestration (`session.py`) is unit-tested against
11+
a fake chat model driving the *real* deepagents graph, with no network: `agent.py`
12+
builds the graph, `render.py` draws the conversation, and the Typer command in
13+
`aai_cli/commands/code/` wires the gateway model + real CLI runner in.
14+
"""
15+
16+
from __future__ import annotations

aai_cli/code_agent/agent.py

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
"""Assemble the deepagents graph for `assembly code`.
2+
3+
Wires the gateway model to deepagents' built-in coding toolset (filesystem + shell,
4+
rooted at the working directory via a `LocalShellBackend`), plus the custom `assembly`
5+
CLI tool and any MCP/docs tools, the installed-skills middleware, and human-in-the-loop
6+
approval on the mutating tools. The compiled graph is driven turn-by-turn from
7+
`session.py`; an `InMemorySaver` checkpointer gives both conversation memory and the
8+
interrupt/resume the approval flow needs.
9+
"""
10+
11+
from __future__ import annotations
12+
13+
from collections.abc import Mapping, Sequence
14+
from pathlib import Path
15+
from typing import TYPE_CHECKING, Protocol
16+
17+
from aai_cli.code_agent.cli_tool import CLI_TOOL_NAME
18+
from aai_cli.code_agent.fetch_tool import FETCH_TOOL_NAME
19+
from aai_cli.code_agent.prompt import build_system_prompt
20+
21+
if TYPE_CHECKING:
22+
from langchain.agents.middleware import AgentMiddleware
23+
from langchain_core.language_models.chat_models import BaseChatModel
24+
from langchain_core.tools import BaseTool
25+
from langgraph.checkpoint.base import BaseCheckpointSaver
26+
27+
# The tools whose effects reach outside the model — file writes, edits, arbitrary
28+
# shell, the AssemblyAI CLI (which can spend account credits), and URL fetches (which
29+
# can reach internal/SSRF targets). Each is gated behind human approval unless the
30+
# session opts into --auto.
31+
MUTATING_TOOLS = ("write_file", "edit_file", "execute", CLI_TOOL_NAME, FETCH_TOOL_NAME)
32+
33+
34+
class CompiledAgent(Protocol):
35+
"""The slice of the compiled langgraph graph the session drives.
36+
37+
A structural type so we needn't name langgraph's deeply-generic
38+
``CompiledStateGraph`` (and don't drag its type params through our code).
39+
"""
40+
41+
def invoke(
42+
self, input: object, config: Mapping[str, object] | None = None
43+
) -> dict[str, object]:
44+
"""Run one step of the graph, returning the updated state (incl. messages)."""
45+
46+
47+
def _interrupt_config(*, auto_approve: bool) -> dict[str, bool] | None:
48+
"""The ``interrupt_on`` map: approve every mutating tool, or ``None`` under --auto."""
49+
if auto_approve:
50+
return None
51+
return dict.fromkeys(MUTATING_TOOLS, True)
52+
53+
54+
def build_agent(
55+
*,
56+
model: BaseChatModel,
57+
root_dir: Path,
58+
tools: Sequence[BaseTool] = (),
59+
middlewares: Sequence[AgentMiddleware] = (),
60+
checkpointer: BaseCheckpointSaver | None = None,
61+
auto_approve: bool = False,
62+
) -> CompiledAgent:
63+
"""Compile the coding agent over ``root_dir`` with ``tools`` and ``middlewares``.
64+
65+
``model`` is the only network seam — tests pass a fake chat model so the real
66+
deepagents graph (filesystem + shell tools, approval, checkpointing) runs offline.
67+
``checkpointer`` defaults to an in-memory saver (one ephemeral session); the command
68+
passes a SQLite saver for persistent, resumable sessions.
69+
"""
70+
from deepagents import create_deep_agent
71+
from deepagents.backends import LocalShellBackend
72+
from langgraph.checkpoint.memory import InMemorySaver
73+
74+
# virtual_mode=True maps the model's "/"-rooted paths under root_dir and blocks
75+
# traversal escapes, so file ops and shell stay inside the working directory.
76+
backend = LocalShellBackend(root_dir=str(root_dir), virtual_mode=True)
77+
78+
return create_deep_agent(
79+
model=model,
80+
backend=backend,
81+
system_prompt=build_system_prompt(str(root_dir)),
82+
tools=list(tools),
83+
middleware=list(middlewares),
84+
interrupt_on=_interrupt_config(auto_approve=auto_approve),
85+
checkpointer=checkpointer if checkpointer is not None else InMemorySaver(),
86+
)

aai_cli/code_agent/ask_tool.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
"""An `ask_user` tool so the agent can ask the user a question mid-task.
2+
3+
deepagents-code ships an AskUser middleware; base deepagents does not, so we add a
4+
small tool. The actual prompting is injected through an :class:`AskBridge`: the Rich
5+
REPL reads a line, the Textual TUI pops an input modal, and tests script the answer —
6+
the tool itself just calls the bridge, so it stays framework-agnostic. It is *not*
7+
approval-gated (it is itself the user interaction).
8+
"""
9+
10+
from __future__ import annotations
11+
12+
from collections.abc import Callable
13+
from dataclasses import dataclass, field
14+
from typing import TYPE_CHECKING
15+
16+
if TYPE_CHECKING:
17+
from langchain_core.tools import BaseTool
18+
19+
ASK_TOOL_NAME = "ask_user"
20+
21+
22+
def _unanswered(_question: str) -> str:
23+
"""Default handler before a front-end registers one: no human is attached."""
24+
return "No user is available to answer; proceed with your best judgment."
25+
26+
27+
@dataclass
28+
class AskBridge:
29+
"""A late-bound seam for asking the user a question.
30+
31+
The agent (and its tools) are built before the front-end exists, so the tool
32+
captures this bridge and the REPL/TUI sets :attr:`handler` once it's running.
33+
"""
34+
35+
handler: Callable[[str], str] = field(default=_unanswered)
36+
37+
def ask(self, question: str) -> str:
38+
return self.handler(question)
39+
40+
41+
def build_ask_tool(bridge: AskBridge) -> BaseTool:
42+
"""Wrap an :class:`AskBridge` as the ``ask_user`` tool."""
43+
from langchain_core.tools import tool
44+
45+
@tool(ASK_TOOL_NAME)
46+
def ask_user(question: str) -> str:
47+
"""Ask the user a clarifying question and return their answer. Use when you
48+
genuinely need information only the user has before continuing."""
49+
return bridge.ask(question)
50+
51+
return ask_user

aai_cli/code_agent/banner.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
"""The `assembly code` startup splash — the ASSEMBLY wordmark + a short intro.
2+
3+
Rendered once at session start (in the TUI transcript and the headless REPL). The
4+
wordmark is the ANSI-Shadow block font; built from a per-letter map so the rows stay
5+
aligned without hand-editing one giant string. The accent is the AssemblyAI brand blue.
6+
"""
7+
8+
from __future__ import annotations
9+
10+
from aai_cli.ui import theme
11+
12+
# The wordmark accent — the AssemblyAI brand blue (Cobolt 400), as a hex literal so it
13+
# renders identically in Rich and Textual without our theme being loaded.
14+
BRAND_HEX = theme.BRAND
15+
16+
# Intro copy, shared by both front-ends so the wording stays in one place.
17+
READY_LINE = "Ready to code! What would you like to build?"
18+
TIP_LINE = "Tip: approve tools as they run, or pass --auto to skip the prompts."
19+
20+
# Each glyph is six rows tall (ANSI-Shadow). Only the letters in "ASSEMBLY" are needed.
21+
_LETTERS: dict[str, list[str]] = {
22+
"A": [" █████╗ ", "██╔══██╗", "███████║", "██╔══██║", "██║ ██║", "╚═╝ ╚═╝"],
23+
"S": ["███████╗", "██╔════╝", "███████╗", "╚════██║", "███████║", "╚══════╝"],
24+
"E": ["███████╗", "██╔════╝", "█████╗ ", "██╔══╝ ", "███████╗", "╚══════╝"],
25+
"M": ["███╗ ███╗", "████╗ ████║", "██╔████╔██║", "██║╚██╔╝██║", "██║ ╚═╝ ██║", "╚═╝ ╚═╝"],
26+
"B": ["██████╗ ", "██╔══██╗", "██████╔╝", "██╔══██╗", "██████╔╝", "╚═════╝ "],
27+
"L": ["██╗ ", "██║ ", "██║ ", "██║ ", "███████╗", "╚══════╝"],
28+
"Y": ["██╗ ██╗", "╚██╗ ██╔╝", " ╚████╔╝ ", " ╚██╔╝ ", " ██║ ", " ╚═╝ "],
29+
}
30+
_ROWS = 6
31+
32+
33+
def wordmark() -> list[str]:
34+
"""The six plain rows of the ASSEMBLY block wordmark."""
35+
return [" ".join(_LETTERS[ch][row] for ch in "ASSEMBLY") for row in range(_ROWS)]
36+
37+
38+
def version() -> str:
39+
"""The CLI version string (e.g. ``v0.1.19``)."""
40+
from aai_cli import __version__
41+
42+
return f"v{__version__}"

aai_cli/code_agent/cli_tool.py

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
"""Expose the AssemblyAI CLI to the agent as a tool.
2+
3+
The agent gets an ``assembly`` tool that runs *this* CLI as a subprocess
4+
(``python -m aai_cli …``), so a coding task can transcribe a file, run an LLM
5+
transform, list transcripts, etc. without the model hand-rolling shell quoting.
6+
7+
Secrets never ride argv (the project-wide rule): the resolved API key is injected
8+
into the child's environment, never appended to the argument list, so it can't leak
9+
into ``ps`` or the model's own transcript of the command it ran.
10+
"""
11+
12+
from __future__ import annotations
13+
14+
import subprocess
15+
import sys
16+
from collections.abc import Callable
17+
from typing import TYPE_CHECKING
18+
19+
from aai_cli.core import config, env
20+
21+
if TYPE_CHECKING:
22+
from langchain_core.tools import BaseTool
23+
24+
# The tool name the model calls and the approval flow gates on.
25+
CLI_TOOL_NAME = "assembly"
26+
27+
# Cap captured output so a chatty command can't blow the model's context window.
28+
_MAX_OUTPUT_CHARS = 20000
29+
# Backstop so a hung command (e.g. a stuck network call) can't wedge the session.
30+
_DEFAULT_TIMEOUT = 600
31+
32+
# A runner takes the CLI argument list and returns the combined, formatted output.
33+
CliRunner = Callable[[list[str]], str]
34+
35+
36+
def _truncate(text: str) -> str:
37+
"""Clip captured output to the context-window budget, marking that we did."""
38+
if len(text) <= _MAX_OUTPUT_CHARS:
39+
return text
40+
return text[:_MAX_OUTPUT_CHARS] + "\n…[output truncated]"
41+
42+
43+
def _format_result(proc: subprocess.CompletedProcess[str]) -> str:
44+
"""Render a finished CLI run as text the model can read: exit code + both streams."""
45+
parts = [f"exit code: {proc.returncode}"]
46+
if proc.stdout:
47+
parts.append(f"stdout:\n{proc.stdout.rstrip()}")
48+
if proc.stderr:
49+
parts.append(f"stderr:\n{proc.stderr.rstrip()}")
50+
return _truncate("\n".join(parts))
51+
52+
53+
def run_assembly(args: list[str], *, api_key: str, timeout: float = _DEFAULT_TIMEOUT) -> str:
54+
"""Run ``assembly <args>`` as a subprocess and return its formatted output.
55+
56+
Invoked as ``python -m aai_cli`` so it's the very CLI in use, independent of
57+
whatever ``assembly`` may (or may not) be on PATH. The key is passed through the
58+
environment, never argv.
59+
"""
60+
proc = subprocess.run(
61+
[sys.executable, "-m", "aai_cli", *args],
62+
capture_output=True,
63+
text=True,
64+
stdin=subprocess.DEVNULL,
65+
env=env.child_env(**{config.ENV_API_KEY: api_key}),
66+
timeout=timeout,
67+
check=False,
68+
)
69+
return _format_result(proc)
70+
71+
72+
def build_cli_tool(runner: CliRunner) -> BaseTool:
73+
"""Wrap a :data:`CliRunner` as the ``assembly`` LangChain tool the agent can call.
74+
75+
The runner is injected so the orchestration is tested without spawning a real
76+
subprocess; the command layer passes :func:`run_assembly` bound to the session's key.
77+
"""
78+
from langchain_core.tools import tool
79+
80+
@tool(CLI_TOOL_NAME)
81+
def assembly(arguments: list[str]) -> str:
82+
"""Run the AssemblyAI CLI. Pass CLI arguments as a list of strings, e.g.
83+
["transcribe", "audio.mp3", "--json"]. Returns the command's exit code and
84+
output. Do not include an API key — it is provided via the environment."""
85+
return runner(arguments)
86+
87+
return assembly

0 commit comments

Comments
 (0)