Skip to content

Commit 80f2a47

Browse files
committed
2 parents 5fef237 + ba3d6eb commit 80f2a47

File tree

13 files changed

+324
-22
lines changed

13 files changed

+324
-22
lines changed

.claude/settings.local.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,10 @@
66
"Bash(./venv/bin/talkpython status:*)",
77
"Bash(./venv/bin/pip install:*)",
88
"Bash(./venv/bin/talkpython:*)",
9-
"WebFetch(domain:badge.fury.io)"
9+
"WebFetch(domain:badge.fury.io)",
10+
"Bash(gh issue view:*)",
11+
"Bash(pyrefly check:*)",
12+
"Bash(uv lock:*)"
1013
]
1114
}
1215
}

.github/workflows/syntax-check.yml

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
name: Python Syntax Check
2+
3+
on:
4+
push:
5+
branches: ['**']
6+
pull_request:
7+
branches: ['**']
8+
9+
jobs:
10+
syntax-check:
11+
runs-on: ubuntu-latest
12+
13+
steps:
14+
- name: Checkout code
15+
uses: actions/checkout@v4
16+
with:
17+
fetch-depth: 0
18+
19+
- name: Set up Python
20+
uses: actions/setup-python@v5
21+
with:
22+
python-version: '3.12'
23+
24+
- name: Install uv
25+
uses: astral-sh/setup-uv@v4
26+
27+
- name: Install ruff
28+
run: uv tool install ruff
29+
30+
- name: Get changed Python files
31+
id: changed-files
32+
uses: tj-actions/changed-files@v45
33+
with:
34+
files: |
35+
**/*.py
36+
37+
- name: Run ruff format check
38+
if: steps.changed-files.outputs.any_changed == 'true'
39+
run: |
40+
echo "Changed files: ${{ steps.changed-files.outputs.all_changed_files }}"
41+
uv tool run ruff format --check ${{ steps.changed-files.outputs.all_changed_files }}
42+
43+
- name: Run ruff check
44+
if: steps.changed-files.outputs.any_changed == 'true'
45+
run: |
46+
uv tool run ruff check ${{ steps.changed-files.outputs.all_changed_files }}

CLAUDE.md

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
# CLAUDE.md — Agent Instructions for talk-python-cli
2+
3+
## Project Overview
4+
5+
CLI client for the Talk Python to Me podcast and Talk Python Training courses.
6+
Wraps a remote MCP server (`https://talkpython.fm/api/mcp`) using JSON-RPC 2.0
7+
over HTTP. The CLI is a **thin client** — all business logic lives on the server.
8+
The CLI handles argument parsing, HTTP transport, and output formatting.
9+
10+
Published on PyPI as `talk-python-cli`. Entry point: `talkpython`.
11+
12+
## Critical Rules
13+
14+
- **Use `uv pip install`, never `pip install`.**
15+
- **Virtual environment is `./venv`, NOT `./.venv`.**
16+
- **After every code edit, run: `ruff format && ruff check --fix`**
17+
- **Use `pyrefly check` to validate type information after changes.**
18+
- Do not add unnecessary abstractions, comments, or docstrings to unchanged code.
19+
- **Update `change-log.md` after every major change** (new features, breaking changes, notable fixes). Follow the [Keep a Changelog](https://keepachangelog.com/) format already in use.
20+
21+
## Build & Run
22+
23+
```bash
24+
# Activate venv
25+
source venv/bin/activate
26+
27+
# Install in editable mode
28+
uv pip install -e ".[dev]"
29+
30+
# Run CLI
31+
talkpython --help
32+
talkpython episodes search "fastapi"
33+
talkpython status
34+
35+
# Run tests
36+
pytest
37+
38+
# Lint & format (ALWAYS after edits)
39+
ruff format && ruff check --fix
40+
41+
# Type check (ALWAYS after edits)
42+
pyrefly check
43+
```
44+
45+
## Project Structure
46+
47+
```
48+
src/talk_python_cli/
49+
__init__.py # Version from importlib.metadata
50+
__main__.py # python -m entry point
51+
app.py # Root Cyclopts app, global options, meta-handler, status cmd
52+
client.py # MCPClient: httpx-based JSON-RPC 2.0 client
53+
formatting.py # Rich output: Markdown panels (text) or JSON
54+
episodes.py # Episode commands (search, get, list, recent, transcript)
55+
guests.py # Guest commands (search, get, list)
56+
courses.py # Course commands (search, get, list)
57+
58+
tests/
59+
conftest.py # Shared fixtures, JSON-RPC response builders
60+
test_client.py # MCPClient tests
61+
test_episodes.py # Episode command tests
62+
test_guests.py # Guest command tests
63+
test_courses.py # Course command tests
64+
```
65+
66+
## Architecture & Key Patterns
67+
68+
### CLI Framework: Cyclopts (not Click, not Typer)
69+
70+
- Root app in `app.py` with sub-apps for episodes, guests, courses.
71+
- **Meta-app launcher** (`@app.meta.default`): intercepts all invocations to
72+
process global options (`--format`, `--url`) before dispatching to subcommands.
73+
- Parameters use `Annotated[type, cyclopts.Parameter(...)]` for docs/defaults.
74+
- Cyclopts auto-converts snake_case commands to kebab-case (e.g. `transcript_vtt``transcript-vtt`).
75+
76+
### Client Pattern
77+
78+
- `MCPClient` in `client.py` wraps httpx for JSON-RPC 2.0 over HTTP.
79+
- Lazy initialization: `_ensure_initialized()` runs MCP handshake on first call.
80+
- Session ID tracked via `Mcp-Session-Id` response header.
81+
- `call_tool(tool_name, arguments)` is the only public API for MCP tool calls.
82+
- Output format sent as URL query param: `?format=json` when JSON mode.
83+
- No authentication required (public API).
84+
85+
### Lazy Client Access (avoids circular imports)
86+
87+
Each command module retrieves the client via a local helper:
88+
```python
89+
def _client():
90+
from talk_python_cli.app import get_client
91+
return get_client()
92+
```
93+
This deferred import avoids circular dependency since `app.py` imports the command modules.
94+
95+
### Output Formatting
96+
97+
- `display(content, format)` in `formatting.py` routes to markdown or JSON renderer.
98+
- Text mode: Rich Markdown panel with "Talk Python" theme (cyan border, monokai code).
99+
- JSON mode on TTY: pretty-printed with syntax highlighting.
100+
- JSON mode piped: compact single-line JSON for scripting.
101+
102+
### Adding a New Command
103+
104+
1. Add function in the appropriate module (`episodes.py`, `guests.py`, `courses.py`).
105+
2. Decorate with `@sub_app.default` or just define as a regular function in the sub-app.
106+
3. Call `_client().call_tool('tool_name', {'arg': value})` to invoke the MCP tool.
107+
4. Pass result to `display(result, _client().output_format)`.
108+
5. Add tests in the corresponding test file using `pytest-httpx` mocks.
109+
110+
### Adding a New Command Group
111+
112+
1. Create `src/talk_python_cli/newgroup.py` with a `cyclopts.App(name='newgroup')`.
113+
2. Register in `app.py`: `app.command(newgroup.sub_app)`.
114+
3. Create `tests/test_newgroup.py`.
115+
116+
## Testing
117+
118+
- Framework: **pytest** with **pytest-httpx** for HTTP mocking.
119+
- `conftest.py` provides helpers: `jsonrpc_result()`, `tool_result()`, `add_init_responses()`.
120+
- Every test must call `add_init_responses(httpx_mock)` before making MCP client calls.
121+
- Tests verify JSON-RPC request structure, argument passing, and response handling.
122+
123+
## Dependencies
124+
125+
| Package | Purpose |
126+
|-------------|--------------------------------|
127+
| cyclopts | CLI framework (commands, args) |
128+
| httpx | HTTP client for MCP calls |
129+
| rich | Terminal output formatting |
130+
| pytest | Testing (dev) |
131+
| pytest-httpx| HTTP mocking in tests (dev) |
132+
133+
Build system: **hatchling**. Package manager: **uv**.
134+
135+
## Config Files
136+
137+
- `pyproject.toml` — Package metadata, dependencies, entry points, build config
138+
- `ruff.toml` — Line length 120, single quotes, target Python 3.14
139+
- `pyrefly.toml` — Type checker config, search path includes `src/`
140+
- `uv.lock` — Locked dependencies (committed)
141+
142+
## Style Conventions
143+
144+
- Line length: 120
145+
- Quotes: single quotes
146+
- Modern Python type syntax: `dict | None` not `Optional[dict]`
147+
- `from __future__ import annotations` in all modules
148+
- Private helpers prefixed with `_`
149+
- Minimal docstrings: only on public functions/classes
150+
- Python target: 3.12+ (currently targeting 3.14 in tooling)

README.md

Lines changed: 27 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -84,41 +84,55 @@ talkpython courses list
8484

8585
## Output formats
8686

87-
The CLI auto-detects the best output format:
87+
The CLI supports three output formats via `--format`:
8888

89-
- **Interactive terminal** — Rich-formatted Markdown with styled panels and color.
90-
- **Piped / redirected** — Compact JSON, ready for processing.
91-
92-
Override the default with `--format`:
89+
- **`text`** (default) — Rich-formatted Markdown with styled panels and color for human reading.
90+
- **`json`** — Structured JSON, pretty-printed on a TTY or compact when piped.
91+
- **`markdown`** — Raw Markdown output with no Rich formatting. Ideal for piping into AI agents, LLMs, and automation tools that consume Markdown natively.
9392

9493
```bash
9594
# Force JSON output in the terminal
9695
talkpython --format json episodes search "async"
9796

97+
# Raw Markdown for AI agents and LLM pipelines
98+
talkpython --format markdown episodes get 535
99+
98100
# Force rich text output even when piping
99101
talkpython --format text episodes recent | less -R
100102
```
101103

104+
## Agentic AI and LLM integration
105+
106+
Use `--format markdown` when feeding output to AI agents, LLMs, or RAG pipelines. This gives you clean, raw Markdown without terminal styling — exactly what language models expect:
107+
108+
```bash
109+
# Feed an episode summary to an LLM
110+
talkpython --format markdown episodes get 535 | llm "Summarize this podcast episode"
111+
112+
# Grab a transcript for RAG ingestion
113+
talkpython --format markdown episodes transcript 535 | your-rag-pipeline ingest
114+
115+
# Pipe course details into an AI agent
116+
talkpython --format markdown courses get 57 | your-agent process
117+
```
118+
102119
## Piping JSON to other tools
103120

104-
Because the CLI outputs JSON automatically when piped, it integrates naturally with tools like `jq`, `llm`, or your own scripts:
121+
The `--format json` output integrates naturally with tools like `jq` or your own scripts:
105122

106123
```bash
107124
# Extract episode titles with jq
108-
talkpython episodes search "testing" | jq '.title'
109-
110-
# Feed episode data into an LLM
111-
talkpython episodes get 535 | llm "Summarize this podcast episode"
125+
talkpython --format json episodes search "testing" | jq '.title'
112126

113-
# Grab a transcript for RAG ingestion
114-
talkpython episodes transcript 535 | your-rag-pipeline ingest
127+
# Process structured data in a script
128+
talkpython --format json episodes recent | python process_episodes.py
115129
```
116130

117131
## Global options
118132

119133
| Option | Description |
120134
|--------|-------------|
121-
| `--format text\|json` | Force output format (auto-detected by default) |
135+
| `--format text\|json\|markdown` | Output format: `text` (rich), `json`, or `markdown` (raw) |
122136
| `--url <mcp-url>` | Override the MCP server URL (default: `https://talkpython.fm/api/mcp`) |
123137
| `--version`, `-V` | Show version |
124138

change-log.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,21 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [0.2.0] - 2026-02-07
9+
10+
### Added
11+
- `--format markdown` output mode for raw Markdown without Rich formatting, ideal for AI agents, LLMs, and RAG pipelines
12+
- New "Agentic AI and LLM integration" section in README
13+
- `display_markdown_raw()` in `formatting.py` for plain stdout output
14+
- Tests for markdown format: query param propagation, raw output, and display routing
15+
16+
### Changed
17+
- `--format` flag now accepts `text`, `json`, or `markdown` (was `text` or `json`)
18+
- MCP client sends `?format=markdown` query param to server when markdown format is selected
19+
- README "Output formats" and "Piping JSON to other tools" sections updated for the new format
20+
21+
---
22+
823
## [0.1.2] - 2026-02-07
924

1025
### Added

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "talk-python-cli"
3-
version = "0.1.2"
3+
version = "0.2.0"
44
description = "CLI for the Talk Python to Me podcast and courses"
55
requires-python = ">=3.12"
66
license = "MIT"

src/talk_python_cli/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,4 @@
22

33
from importlib.metadata import version
44

5-
__version__ = version("talk-python-cli")
5+
__version__ = version('talk-python-cli')

src/talk_python_cli/app.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -82,10 +82,10 @@ def status() -> None:
8282
def launcher(
8383
*tokens: Annotated[str, cyclopts.Parameter(show=False, allow_leading_hyphen=True)],
8484
format: Annotated[
85-
Literal['text', 'json'],
85+
Literal['text', 'json', 'markdown'],
8686
cyclopts.Parameter(
8787
name='--format',
88-
help="Output format: 'text' (rich Markdown) or 'json'.",
88+
help="Output format: 'text' (rich Markdown), 'json', or 'markdown' (raw).",
8989
),
9090
] = 'text',
9191
url: Annotated[

src/talk_python_cli/client.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,8 +46,8 @@ def _next_id(self) -> int:
4646
return self._msg_id
4747

4848
def _url(self) -> str:
49-
if self.output_format == 'json':
50-
return f'{self.base_url}?format=json'
49+
if self.output_format in ('json', 'markdown'):
50+
return f'{self.base_url}?format={self.output_format}'
5151
return self.base_url
5252

5353
def _post(self, payload: dict) -> httpx.Response:

src/talk_python_cli/formatting.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,10 +39,17 @@ def display(content: str, output_format: str) -> None:
3939
"""Route content to the appropriate renderer."""
4040
if output_format == 'json':
4141
display_json(content)
42+
elif output_format == 'markdown':
43+
display_markdown_raw(content)
4244
else:
4345
display_markdown(content)
4446

4547

48+
def display_markdown_raw(content: str) -> None:
49+
"""Print raw Markdown content to stdout without any Rich formatting."""
50+
print(content)
51+
52+
4653
def display_markdown(content: str) -> None:
4754
"""Render Markdown content with Rich, wrapped in a styled panel."""
4855
md = Markdown(content, code_theme='monokai')
@@ -61,7 +68,7 @@ def display_json(content: str) -> None:
6168
"""Output JSON content — pretty-printed if on a TTY, raw otherwise."""
6269
try:
6370
data = json.loads(content)
64-
except json.JSONDecodeError, TypeError:
71+
except (json.JSONDecodeError, TypeError):
6572
# Server may have returned Markdown even though JSON was requested;
6673
# fall back to printing the raw text.
6774
console.print(content)

0 commit comments

Comments
 (0)