Skip to content

Commit cd5bba6

Browse files
committed
llm: accept a directory argument, recursing for .md/.txt
A directory passed to `assembly llm` now expands (via rglob, path-sorted) into the .md/.txt files it holds, each read under its own header just like explicit file arguments. An empty directory is a usage error raised before auth/network — the empty-guard the justfile's `transcripts/**/*.md(.N)` glob used to carry now that the CLI can. This lets `assembly llm "…" transcripts/` replace the glob-and-guard a caller would otherwise hand-roll. Split the file/directory-context tests out of test_llm_command.py into test_llm_files.py to stay under the 500-line gate. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LwoV9EXHTakfpw9E5hp89G
1 parent 2cd2dd7 commit cd5bba6

6 files changed

Lines changed: 290 additions & 141 deletions

File tree

‎README.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -321,10 +321,11 @@ ffmpeg -i talk.mp4 -f wav - | assembly transcribe -
321321
git log --oneline -30 | assembly llm "write release notes grouped by feature/fix"
322322
```
323323

324-
Pass files straight to `llm` instead of building the pipeline yourself — each is read, prefixed with a `===== name =====` header, and concatenated as the prompt's context (so the answer can cite which note it came from):
324+
Pass files — or a whole directory — straight to `llm` instead of building the pipeline yourself — each is read, prefixed with a `===== name =====` header, and concatenated as the prompt's context (so the answer can cite which note it came from). A directory argument recurses for its `.md`/`.txt` files:
325325

326326
```sh
327327
assembly llm "answer using only these notes: who owns the deploy?" notes/*.md
328+
assembly llm "summarize the key decisions" transcripts/
328329
```
329330

330331
## 📚 Documentation

‎aai_cli/commands/llm/__init__.py‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -44,8 +44,8 @@ def _list_models(output_field: choices.TextOrJson | None, json_mode: bool) -> No
4444
),
4545
("Pipe any text in", 'echo "meeting notes" | assembly llm "turn into action items"'),
4646
(
47-
"Read one or more files as context",
48-
'assembly llm "answer using only these notes: who owns the deploy?" notes/*.md',
47+
"Read files or a whole directory as context",
48+
'assembly llm "summarize the key decisions" transcripts/',
4949
),
5050
(
5151
"Pick a model and add a system prompt",
@@ -60,8 +60,9 @@ def llm(
6060
prompt: str | None = typer.Argument(None, help="The prompt to send to the model"),
6161
files: list[Path] | None = typer.Argument(
6262
None,
63-
help="Optional input files to read as the prompt's context (each is header-prefixed "
64-
"with its name and concatenated; takes priority over piped stdin)",
63+
help="Optional input files or directories to read as the prompt's context "
64+
"(a directory recurses for .md/.txt files; each is header-prefixed with its "
65+
"name and concatenated; takes priority over piped stdin)",
6566
),
6667
# Note: text piped on stdin is injected into the prompt (e.g. `cat notes | assembly llm "summarize"`).
6768
model: str = typer.Option(

‎aai_cli/commands/llm/_exec.py‎

Lines changed: 38 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,9 @@
2727
'`assembly stream -o text | assembly llm -f "summarize action items as I talk"`.'
2828
)
2929

30+
# Suffixes a directory argument recurses for (matched case-insensitively).
31+
_DIR_SUFFIXES = (".md", ".txt")
32+
3033

3134
@dataclass(frozen=True)
3235
class LlmOptions:
@@ -82,17 +85,46 @@ def _validate_follow_args(
8285
return prompt
8386

8487

88+
def _expand_paths(files: tuple[Path, ...]) -> tuple[Path, ...]:
89+
"""Expand any directory argument into the ``.md``/``.txt`` files it holds.
90+
91+
A directory recurses (depth-first via ``rglob``, path-sorted so the header
92+
order is deterministic) for files whose suffix is in ``_DIR_SUFFIXES``; a plain
93+
file passes through unchanged. A directory holding no matching files is a usage
94+
error — the empty-check the justfile's ``transcripts/**/*.md(.N)`` glob used to
95+
carry, now that the CLI can. This lets `assembly llm "…" transcripts/` stand in
96+
for the glob-and-guard the caller would otherwise hand-roll.
97+
"""
98+
expanded: list[Path] = []
99+
for path in files:
100+
if not path.is_dir():
101+
expanded.append(path)
102+
continue
103+
matches = sorted(
104+
p for p in path.rglob("*") if p.is_file() and p.suffix.lower() in _DIR_SUFFIXES
105+
)
106+
if not matches:
107+
raise UsageError(
108+
f"No {' or '.join(_DIR_SUFFIXES)} files found in {path}.",
109+
suggestion="Point at a directory containing .md or .txt notes, "
110+
"or pass files directly.",
111+
)
112+
expanded.extend(matches)
113+
return tuple(expanded)
114+
115+
85116
def _read_files(files: tuple[Path, ...]) -> str:
86117
"""Read each file and join them, each prefixed with a ``===== name =====`` header.
87118
88-
The header names each source (the file's stem) so a multi-file prompt can cite
89-
which note an answer came from; it's applied uniformly, even for a single file,
90-
so the format the model sees is predictable. A missing or unreadable path is a
91-
usage error raised before any auth or network — the same fail-fast ordering as
92-
the --transcript-id check.
119+
A directory argument is first expanded into its ``.md``/``.txt`` files
120+
(see ``_expand_paths``). The header names each source (the file's stem) so a
121+
multi-file prompt can cite which note an answer came from; it's applied
122+
uniformly, even for a single file, so the format the model sees is predictable.
123+
A missing or unreadable path is a usage error raised before any auth or
124+
network — the same fail-fast ordering as the --transcript-id check.
93125
"""
94126
sections: list[str] = []
95-
for path in files:
127+
for path in _expand_paths(files):
96128
try:
97129
text = path.read_text(encoding="utf-8")
98130
except OSError as exc:

‎tests/__snapshots__/test_snapshots_help_run.ambr‎

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -592,9 +592,11 @@
592592

593593
╭─ Arguments ──────────────────────────────────────────────────────────────────╮
594594
│ prompt [PROMPT] The prompt to send to the model │
595-
│ files [FILES]... Optional input files to read as the prompt's │
596-
│ context (each is header-prefixed with its name and │
597-
│ concatenated; takes priority over piped stdin) │
595+
│ files [FILES]... Optional input files or directories to read as the │
596+
│ prompt's context (a directory recurses for │
597+
│ .md/.txt files; each is header-prefixed with its │
598+
│ name and concatenated; takes priority over piped │
599+
│ stdin) │
598600
╰──────────────────────────────────────────────────────────────────────────────╯
599601
╭─ Options ────────────────────────────────────────────────────────────────────╮
600602
│ --model TEXT LLM Gateway model │
@@ -634,9 +636,8 @@
634636
$ assembly llm "summarize the key decisions" --transcript-id 5551234-abcd
635637
Pipe any text in
636638
$ echo "meeting notes" | assembly llm "turn into action items"
637-
Read one or more files as context
638-
$ assembly llm "answer using only these notes: who owns the deploy?"
639-
notes/*.md
639+
Read files or a whole directory as context
640+
$ assembly llm "summarize the key decisions" transcripts/
640641
Pick a model and add a system prompt
641642
$ assembly llm "draft a follow-up email" --model claude-opus-4-7 --system "Be
642643
concise."

‎tests/test_llm_command.py‎

Lines changed: 0 additions & 124 deletions
Original file line numberDiff line numberDiff line change
@@ -128,130 +128,6 @@ def fake_complete(api_key, *, model, messages, max_tokens, transcript_id=None, e
128128
assert seen["transcript_id"] is None
129129

130130

131-
def test_llm_reads_file_argument_as_context(monkeypatch, tmp_path):
132-
_auth()
133-
seen = {}
134-
135-
def fake_complete(api_key, *, model, messages, max_tokens, transcript_id=None, extra=None):
136-
seen["content"] = messages[0]["content"]
137-
seen["transcript_id"] = transcript_id
138-
return _payload("done")
139-
140-
monkeypatch.setattr("aai_cli.commands.llm.gateway.complete", fake_complete)
141-
note = tmp_path / "alpha.md"
142-
note.write_text("bob owns the deploy")
143-
result = runner.invoke(app, ["llm", "who owns the deploy?", str(note), "--json"])
144-
assert result.exit_code == 0
145-
# The file content is injected, under a header naming the file's stem.
146-
assert "who owns the deploy?" in seen["content"]
147-
assert "bob owns the deploy" in seen["content"]
148-
assert "===== alpha =====" in seen["content"]
149-
assert seen["transcript_id"] is None
150-
151-
152-
def test_llm_concatenates_multiple_files_with_headers_in_order(monkeypatch, tmp_path):
153-
_auth()
154-
seen = {}
155-
156-
def fake_complete(api_key, *, model, messages, max_tokens, transcript_id=None, extra=None):
157-
seen["content"] = messages[0]["content"]
158-
return _payload("done")
159-
160-
monkeypatch.setattr("aai_cli.commands.llm.gateway.complete", fake_complete)
161-
first = tmp_path / "first.md"
162-
first.write_text("ship friday")
163-
second = tmp_path / "second.md"
164-
second.write_text("freeze monday")
165-
result = runner.invoke(app, ["llm", "summarize", str(first), str(second), "--json"])
166-
assert result.exit_code == 0
167-
content = seen["content"]
168-
assert "===== first =====" in content
169-
assert "===== second =====" in content
170-
assert "ship friday" in content
171-
assert "freeze monday" in content
172-
# Both note bodies appear under their own header, in the order passed.
173-
assert content.index("===== first =====") < content.index("===== second =====")
174-
assert content.index("ship friday") < content.index("freeze monday")
175-
176-
177-
def test_llm_files_take_priority_over_stdin(monkeypatch, tmp_path):
178-
_auth()
179-
seen = {}
180-
181-
def fake_complete(api_key, *, model, messages, max_tokens, transcript_id=None, extra=None):
182-
seen["content"] = messages[0]["content"]
183-
return _payload("done")
184-
185-
monkeypatch.setattr("aai_cli.commands.llm.gateway.complete", fake_complete)
186-
note = tmp_path / "note.md"
187-
note.write_text("from the file")
188-
result = runner.invoke(
189-
app, ["llm", "summarize", str(note)], input="from stdin, should be ignored"
190-
)
191-
assert result.exit_code == 0
192-
assert "from the file" in seen["content"]
193-
assert "from stdin, should be ignored" not in seen["content"]
194-
assert "Ignoring piped stdin; file arguments take priority." in result.output
195-
196-
197-
def test_llm_missing_file_exits_2_without_network(monkeypatch, tmp_path):
198-
# A bad path (e.g. an unmatched shell glob passed through literally) is a usage
199-
# error raised before auth or the gateway, not a crash.
200-
_auth()
201-
monkeypatch.setattr(
202-
"aai_cli.commands.llm.gateway.complete",
203-
lambda *a, **k: (_ for _ in ()).throw(AssertionError("must not call the gateway")),
204-
)
205-
missing = tmp_path / "nope.md"
206-
result = runner.invoke(app, ["llm", "summarize", str(missing)])
207-
assert result.exit_code == 2
208-
assert "Couldn't read" in result.output
209-
# The clean OS reason (errno's strerror) is shown, not the raw exception repr —
210-
# so no "[Errno N] …: '/path'" bracket leaks into the message.
211-
assert "[Errno" not in result.output
212-
213-
214-
def test_llm_files_with_terminal_stdin_emits_no_warning(monkeypatch, tmp_path):
215-
# With files given and stdin a terminal (not piped), there's nothing being
216-
# ignored, so the "Ignoring piped stdin" warning must not fire.
217-
_auth()
218-
monkeypatch.setattr("aai_cli.commands.llm._exec.stdio.stdin_is_piped", lambda: False)
219-
seen = {}
220-
221-
def fake_complete(api_key, *, model, messages, max_tokens, transcript_id=None, extra=None):
222-
seen["content"] = messages[0]["content"]
223-
return _payload("done")
224-
225-
monkeypatch.setattr("aai_cli.commands.llm.gateway.complete", fake_complete)
226-
note = tmp_path / "note.md"
227-
note.write_text("only the file")
228-
result = runner.invoke(app, ["llm", "summarize", str(note)])
229-
assert result.exit_code == 0
230-
assert "only the file" in seen["content"]
231-
assert "Ignoring piped stdin" not in result.output
232-
233-
234-
def test_llm_transcript_id_takes_priority_over_files(monkeypatch, tmp_path):
235-
_auth()
236-
seen = {}
237-
# Pin stdin to a terminal so only the file argument is the ignored source.
238-
monkeypatch.setattr("aai_cli.commands.llm._exec.stdio.stdin_is_piped", lambda: False)
239-
240-
def fake_complete(api_key, *, model, messages, max_tokens, transcript_id=None, extra=None):
241-
seen["content"] = messages[0]["content"]
242-
seen["transcript_id"] = transcript_id
243-
return _payload("s")
244-
245-
monkeypatch.setattr("aai_cli.commands.llm.gateway.complete", fake_complete)
246-
note = tmp_path / "note.md"
247-
note.write_text("file content here")
248-
result = runner.invoke(app, ["llm", "summarize", str(note), "--transcript-id", "t_9"])
249-
assert result.exit_code == 0
250-
assert seen["transcript_id"] == "t_9"
251-
assert "file content here" not in seen["content"]
252-
assert "Ignoring file arguments; --transcript-id takes priority." in result.output
253-
254-
255131
def test_llm_transcript_id_takes_priority_over_stdin(monkeypatch):
256132
_auth()
257133
seen = {}

0 commit comments

Comments
 (0)