Skip to content
Open
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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,5 @@ __pycache__/
.pytest_cache/
*.egg-info/
.venv/
tests/_config_home/
GEMINI.md
117 changes: 117 additions & 0 deletions CLI.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
# Feedscope CLI Documentation

Feedscope CLI provides commands to interact with the Feedbin API.

## Authentication

```bash
feedscope auth login
feedscope auth status
feedscope auth logout
```

## Entries

Retrieve and filter entries.

```bash
# List entries
feedscope entries list [--page N] [--since DATE] [--read/--no-read] [--starred]

# Show an entry
feedscope entries show <ENTRY_ID>

# List entries for a feed
feedscope entries feed <FEED_ID>
```

## Entry State

Manage unread, starred, and updated entries.

```bash
# Unread
feedscope unread list
feedscope unread mark-read <ID>...
feedscope unread mark-unread <ID>...

# Starred
feedscope starred list
feedscope starred star <ID>...
feedscope starred unstar <ID>...

# Updated
feedscope updated list [--include-diff]
feedscope updated mark-read <ID>...

# Recently Read
feedscope recently-read list
feedscope recently-read create <ID>...
```

## Saved Searches

```bash
feedscope saved-search list
feedscope saved-search get <ID> [--include-entries]
feedscope saved-search create --name "Name" --query "Query"
feedscope saved-search update <ID> [--name "Name"] [--query "Query"]
feedscope saved-search delete <ID>
```

## Tags & Taggings

```bash
# Tags
feedscope tags rename --old-name "Old" --new-name "New"
feedscope tags delete --name "Tag"

# Taggings
feedscope taggings list
feedscope taggings create --feed-id <ID> --name "Tag"
feedscope taggings delete <TAGGING_ID>
```

## Subscriptions

```bash
feedscope subscriptions list
feedscope subscriptions get <ID>...
feedscope subscriptions create <URL>
feedscope subscriptions update <ID> "New Title"
feedscope subscriptions delete <ID>
```

## Supporting Tools

```bash
# Imports (OPML)
feedscope imports list
feedscope imports create <OPML_FILE>
feedscope imports status <IMPORT_ID>

# Pages
feedscope pages save --url <URL>

# Icons
feedscope icons list

# Extract Content
feedscope extract <URL>
```

## Configuration

Configuration is stored in `~/.config/dev.pirateninja.feedscope/config.toml` (or platform equivalent).

To use the extraction service, add your API credentials to the config file:

```toml
[auth]
email = "..."
password = "..."

[extract]
username = "..."
secret = "..."
```
50 changes: 25 additions & 25 deletions plans/2025-11-19-subcommand-implementation.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,39 +10,39 @@
- Deliver the Feedbin-aligned subcommands outlined in `plans/2025-11-19-agents-preferences.md`, starting with entries retrieval, state management (unread/starred/updated/recently read), saved searches/tags, imports/pages/icons, and support utilities such as feed metadata and the full-content extractor.

## Infrastructure tasks
- [ ] Introduce new Typer sub-app modules (e.g., `entries.py`, `state.py`, `searches.py`, `utils.py`) so the main `feedscope` app can keep concerns separate while still using Click under the hood.
- [ ] Add `loguru` via `uv add` and use it consistently for debug/info messages inside the new command modules; keep user-facing output via `typer.echo`.
- [ ] Create a `tests/` directory (per AGENTS) and populate it with CLI-focused pytest files that use Typer’s `CliRunner` to simulate commands.
- [ ] Document `uv run pytest`, `uv run ruff`, and `uv run ty` in README/CONTRIBUTING if needed (so future contributors remember AGENTS requirements).
- [ ] Add the `stamina` retry/backoff library via `uv add` and wrap `httpx` requests with its policies so the CLI gracefully handles transient errors for GET/DELETE requests, logging retries through `loguru`.
- [ ] Ensure the cached `CacheClient` from `hishel` is configured to store responses for safe GET-like requests; make cache-control decisions explicit so stale data isn't re-used for write operations.
- [x] Introduce new Typer sub-app modules (e.g., `entries.py`, `state.py`, `searches.py`, `utils.py`) so the main `feedscope` app can keep concerns separate while still using Click under the hood.
- [x] Add `loguru` via `uv add` and use it consistently for debug/info messages inside the new command modules; keep user-facing output via `typer.echo`.
- [x] Create a `tests/` directory (per AGENTS) and populate it with CLI-focused pytest files that use Typer’s `CliRunner` to simulate commands.
- [x] Document `uv run pytest`, `uv run ruff`, and `uv run ty` in README/CONTRIBUTING if needed (so future contributors remember AGENTS requirements).
- [x] Add the `stamina` retry/backoff library via `uv add` and wrap `httpx` requests with its policies so the CLI gracefully handles transient errors for GET/DELETE requests, logging retries through `loguru`.
- [x] Ensure the cached `CacheClient` from `hishel` is configured to store responses for safe GET-like requests; make cache-control decisions explicit so stale data isn't re-used for write operations.

## Phase 1: Entries & feed metadata
- [ ] Build `feedscope entries list` with support for `--since`, `--page`, `--per-page`, `--read/--starred`, `--mode`, `--include-original`, `--include-enclosure`, and `--include-content-diff`, matching `content/entries.md`.
- [ ] Add `feedscope entries show <entry-id>` to fetch `GET /v2/entries/<id>.json` along with error handling for status codes listed in `content/entries.md`.
- [ ] Implement `feedscope entries feed <feed-id>` (or similar) to wrap `GET /v2/feeds/<id>/entries.json` and honor the same filters.
- [ ] Write tests verifying query parameter serialization and response handling (mock `httpx.Client` via `respx` or similar) for each command.
- [x] Build `feedscope entries list` with support for `--since`, `--page`, `--per-page`, `--read/--starred`, `--mode`, `--include-original`, `--include-enclosure`, and `--include-content-diff`, matching `content/entries.md`.
- [x] Add `feedscope entries show <entry-id>` to fetch `GET /v2/entries/<id>.json` along with error handling for status codes listed in `content/entries.md`.
- [x] Implement `feedscope entries feed <feed-id>` (or similar) to wrap `GET /v2/feeds/<id>/entries.json` and honor the same filters.
- [x] Write tests verifying query parameter serialization and response handling (mock `httpx.Client` via `respx` or similar) for each command.

## Phase 2: Entry state management
- [ ] Provide `feedscope unread list` plus `mark-read`/`mark-unread` commands that POST/DELETE `unread_entries` per `content/unread-entries.md`, enforcing the 1,000-entry limit with validation.
- [ ] Mirror that behavior for `feedscope starred list/star/unstar` to match `content/starred-entries.md`.
- [ ] Add `feedscope updated list` and `feedscope updated mark-read` using `content/updated-entries.md`, reusing the entry-fetch helpers from Phase 1 to display diffs when `--include-diff` is requested.
- [ ] Create `feedscope recently-read list/create` per `content/recently-read-entries.md`.
- [ ] Cover these commands with dedicated tests that mock the ID arrays and confirm the right HTTP verb/payload is sent.
- [x] Provide `feedscope unread list` plus `mark-read`/`mark-unread` commands that POST/DELETE `unread_entries` per `content/unread-entries.md`, enforcing the 1,000-entry limit with validation.
- [x] Mirror that behavior for `feedscope starred list/star/unstar` to match `content/starred-entries.md`.
- [x] Add `feedscope updated list` and `feedscope updated mark-read` using `content/updated-entries.md`, reusing the entry-fetch helpers from Phase 1 to display diffs when `--include-diff` is requested.
- [x] Create `feedscope recently-read list/create` per `content/recently-read-entries.md`.
- [x] Cover these commands with dedicated tests that mock the ID arrays and confirm the right HTTP verb/payload is sent.

## Phase 3: Saved searches, tags & taggings
- [ ] Add `feedscope saved-search list`, `get`, `create`, `update`, and `delete` commands following `content/saved-searches.md`, including `--include-entries` and pagination options.
- [ ] Provide `feedscope tags rename`/`delete` and `feedscope taggings list/create/delete` inspired by `content/tags.md` and `content/taggings.md`.
- [ ] Ensure CLI output exposes the relevant JSON arrays (e.g., after rename/delete the updated taggings array) and write pytest coverage for success/failure paths.
- [x] Add `feedscope saved-search list`, `get`, `create`, `update`, and `delete` commands following `content/saved-searches.md`, including `--include-entries` and pagination options.
- [x] Provide `feedscope tags rename`/`delete` and `feedscope taggings list/create/delete` inspired by `content/tags.md` and `content/taggings.md`.
- [x] Ensure CLI output exposes the relevant JSON arrays (e.g., after rename/delete the updated taggings array) and write pytest coverage for success/failure paths.

## Phase 4: Supporting APIs
- [ ] Implement `feedscope imports create|list|status` that uploads OPML, sets `Content-Type: text/xml`, and re-uses the client cache.
- [ ] Provide `feedscope pages save` to POST URLs/titles (`content/pages.md`) and return the created entry payload.
- [ ] Add `feedscope icons list` for `GET /v2/icons.json` and consider caching or optional JSONL output for scripting.
- [ ] Create an `extract` command that, given credentials stored in config (new `extract.username`/`extract.secret` entries), builds the HMAC-SHA1 signature as in `content/extract-full-content.md` before fetching parse results.
- [ ] Ensure each API helper has a test that mocks `httpx` responses and validates that required headers/payloads are constructed correctly.
- [x] Implement `feedscope imports create|list|status` that uploads OPML, sets `Content-Type: text/xml`, and re-uses the client cache.
- [x] Provide `feedscope pages save` to POST URLs/titles (`content/pages.md`) and return the created entry payload.
- [x] Add `feedscope icons list` for `GET /v2/icons.json` and consider caching or optional JSONL output for scripting.
- [x] Create an `extract` command that, given credentials stored in config (new `extract.username`/`extract.secret` entries), builds the HMAC-SHA1 signature as in `content/extract-full-content.md` before fetching parse results.
- [x] Ensure each API helper has a test that mocks `httpx` responses and validates that required headers/payloads are constructed correctly.

## Phase 5: Workflow & polishing
- [ ] Update the README (or add CLI docs) to describe the new commands, referencing the content docs as the API source of truth.
- [ ] Run `uv run ruff format`, `uv run ty`, and `uv run pytest` after implementing each phase to keep the codebase clean.
- [x] Update the README (or add CLI docs) to describe the new commands, referencing the content docs as the API source of truth.
- [x] Run `uv run ruff format`, `uv run ty`, and `uv run pytest` after implementing each phase to keep the codebase clean.
- [ ] Optional: expose `feedscope auth status` improvements or helper `feedscope config show` if needed to expose additional configuration fields (e.g., Extract credentials).
4 changes: 3 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ dependencies = [
"rich>=14.1.0",
"hishel>=0.0.36",
"loguru-config",
"stamina>=25.2.0",
]

[project.scripts]
Expand All @@ -30,14 +31,15 @@ build-backend = "setuptools.build_meta"
dev = [
"poethepoet>=0.32.2",
"pytest>=8.3.3",
"respx>=0.22.0",
"ruff>=0.7.3",
"ty==0.0.1a27",
]

[tool.poe.tasks]
lint = { cmd = "uv run ruff check src tests" }
format = { cmd = "uv run ruff format src tests" }
typecheck = { cmd = "uv run ty src/feedscope" }
typecheck = { cmd = "uv run ty check src/feedscope" }
test = { cmd = "uv run pytest" }
qa = { sequence = ["lint", "typecheck", "test"] }

Expand Down
17 changes: 17 additions & 0 deletions src/feedscope/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@
from .config_cli import config_app
from .state import AppState
from .subscriptions import subscriptions_app
from .entries import entries_app
from .searches import searches_app
from .entry_state import unread_app, starred_app, updated_app, recently_read_app
from .tags import tags_app, taggings_app
from .supporting import imports_app, pages_app, icons_app, extract_command


def configure_logging(config_file: Path | None) -> AppState:
Expand Down Expand Up @@ -42,6 +47,18 @@ def configure_logging(config_file: Path | None) -> AppState:
app.add_typer(auth_app, name="auth")
app.add_typer(config_app, name="config")
app.add_typer(subscriptions_app, name="subscriptions")
app.add_typer(entries_app, name="entries")
app.add_typer(searches_app, name="saved-search")
app.add_typer(unread_app, name="unread")
app.add_typer(starred_app, name="starred")
app.add_typer(updated_app, name="updated")
app.add_typer(recently_read_app, name="recently-read")
app.add_typer(tags_app, name="tags")
app.add_typer(taggings_app, name="taggings")
app.add_typer(imports_app, name="imports")
app.add_typer(pages_app, name="pages")
app.add_typer(icons_app, name="icons")
app.command(name="extract")(extract_command)


@app.callback()
Expand Down
1 change: 1 addition & 0 deletions src/feedscope/__main__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""
This module allows the package to be run as a script.
"""

from . import main

if __name__ == "__main__":
Expand Down
21 changes: 15 additions & 6 deletions src/feedscope/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,9 @@
def login(
ctx: typer.Context,
email: Annotated[str, typer.Argument(help="Feedbin email address")],
password: Annotated[str, typer.Option("--password", "-p", help="Feedbin password", hide_input=True)] = None,
password: Annotated[
str, typer.Option("--password", "-p", help="Feedbin password", hide_input=True)
] = None,
) -> None:
"""Check authentication credentials with Feedbin API."""

Expand Down Expand Up @@ -54,7 +56,8 @@ def login(
raise typer.Exit(1)
else:
typer.echo(
f"❌ Unexpected response: {response.status_code}", color=typer.colors.RED
f"❌ Unexpected response: {response.status_code}",
color=typer.colors.RED,
)
raise typer.Exit(1)

Expand Down Expand Up @@ -92,7 +95,8 @@ def status(ctx: typer.Context) -> None:
typer.echo("✅ Authentication successful!", color=typer.colors.GREEN)
elif response.status_code == 401:
typer.echo(
"❌ Authentication failed - invalid credentials.", color=typer.colors.RED
"❌ Authentication failed - invalid credentials.",
color=typer.colors.RED,
)
typer.echo(
"Please run `feedscope auth login` to update your credentials.",
Expand All @@ -101,7 +105,8 @@ def status(ctx: typer.Context) -> None:
raise typer.Exit(1)
else:
typer.echo(
f"❌ Unexpected response: {response.status_code}", color=typer.colors.RED
f"❌ Unexpected response: {response.status_code}",
color=typer.colors.RED,
)
raise typer.Exit(1)

Expand All @@ -114,7 +119,9 @@ def status(ctx: typer.Context) -> None:
def whoami(ctx: typer.Context) -> None:
"""Show the current user from the config file."""
state = get_state(ctx)
logger.debug("Inspecting current auth user with log config {}", state.log_config_path)
logger.debug(
"Inspecting current auth user with log config {}", state.log_config_path
)
config = get_config()

if config.auth.email and config.auth.password:
Expand All @@ -129,7 +136,9 @@ def whoami(ctx: typer.Context) -> None:
def remove(ctx: typer.Context) -> None:
"""Remove stored authentication credentials."""
state = get_state(ctx)
logger.debug("Removing stored credentials with log config {}", state.log_config_path)
logger.debug(
"Removing stored credentials with log config {}", state.log_config_path
)
config = get_config()
config_file = config.config_file_path

Expand Down
30 changes: 28 additions & 2 deletions src/feedscope/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,35 @@
from hishel import CacheClient, FileStorage
from platformdirs import user_cache_dir
from pathlib import Path
import stamina


class FeedscopeClient(CacheClient):
"""Custom client that adds retries for safe methods."""

def request(self, method: str, url, **kwargs) -> httpx.Response:
# Only retry safe methods or DELETE (as per plan "GET/DELETE")
if method.upper() in ["GET", "DELETE", "HEAD", "OPTIONS"]:
try:
for attempt in stamina.retry_context(
on=(httpx.RequestError, httpx.HTTPStatusError), attempts=3
):
with attempt:
response = super().request(method, url, **kwargs)
# Trigger retry on server errors
if response.status_code >= 500:
response.raise_for_status()
return response
except httpx.HTTPStatusError as e:
# If retries exhausted for 5xx, return the last response
return e.response
# RequestError will bubble up if retries exhausted

return super().request(method, url, **kwargs)


def get_client() -> httpx.Client:
"""Get a cached httpx client."""
"""Get a cached httpx client with retries."""
cache_dir = Path(user_cache_dir("dev.pirateninja.feedscope", "http-cache"))
storage = FileStorage(base_path=cache_dir)
return CacheClient(storage=storage)
return FeedscopeClient(storage=storage)
17 changes: 16 additions & 1 deletion src/feedscope/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,20 @@ class AuthCredentials(BaseModel):
password: str = ""


class ExtractCredentials(BaseModel):
"""Extraction service credentials."""

username: str = ""
secret: str = ""


class FeedscopeConfig(BaseSettings):
model_config = SettingsConfigDict(
toml_file=Path(user_config_dir("dev.pirateninja.feedscope")) / "config.toml",
)

auth: AuthCredentials = AuthCredentials()
extract: ExtractCredentials = ExtractCredentials()

@classmethod
def settings_customise_sources(
Expand Down Expand Up @@ -58,13 +66,20 @@ def save(self) -> None:
else:
doc = tomlkit.document()

# Update values
# Update auth
if "auth" not in doc or not isinstance(doc.get("auth"), dict):
doc["auth"] = tomlkit.table()

doc["auth"]["email"] = self.auth.email
doc["auth"]["password"] = self.auth.password

# Update extract
if "extract" not in doc or not isinstance(doc.get("extract"), dict):
doc["extract"] = tomlkit.table()

doc["extract"]["username"] = self.extract.username
doc["extract"]["secret"] = self.extract.secret

if "email" in doc:
del doc["email"]
if "password" in doc:
Expand Down
Loading