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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ node_modules/
test-results/
playwright-report/
temp/
dist/
2 changes: 2 additions & 0 deletions backend/migrations/0048_oauth_state_cli_port.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-- Add cli_port column for CLI OAuth flow
ALTER TABLE oauth_state ADD COLUMN cli_port INTEGER;
16 changes: 16 additions & 0 deletions backend/src/routes/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,10 @@ export async function handleAuthLogin(request: Request, env: Env): Promise<Respo
// Capture the frontend URL from the request origin for redirect after OAuth
const frontendUrl = getValidatedFrontendUrl(request, env);

// CLI mode: capture the local callback port
const cliPortParam = url.searchParams.get('cli_port');
const cliPort = cliPortParam ? parseInt(cliPortParam, 10) : undefined;

if (!handle) {
return new Response(JSON.stringify({ error: 'Missing handle parameter' }), {
status: 400,
Expand Down Expand Up @@ -230,6 +234,7 @@ export async function handleAuthLogin(request: Request, env: Env): Promise<Respo
authServer: authMeta.issuer,
returnUrl,
frontendUrl,
cliPort,
});

const baseUrl = getBaseUrl(url);
Expand Down Expand Up @@ -637,6 +642,17 @@ export async function handleAuthCallback(
path: '/',
});

// CLI mode: redirect to local CLI server instead of frontend
if (oauthState.cliPort) {
const cliRedirectUrl = `http://127.0.0.1:${oauthState.cliPort}/callback?session_id=${encodeURIComponent(sessionId)}`;
return new Response(null, {
status: 302,
headers: {
Location: cliRedirectUrl,
},
});
}

// Redirect to frontend with cookie set (no exchange code needed)
// Validate returnUrl again in case stored state was tampered with
const rawReturnUrl = oauthState.returnUrl || '/';
Expand Down
7 changes: 5 additions & 2 deletions backend/src/services/oauth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -294,8 +294,8 @@ export async function storeOAuthState(env: Env, state: string, data: OAuthState)
const expiresAt = Date.now() + 600 * 1000; // 10 minutes
await env.DB.prepare(
`
INSERT INTO oauth_state (state, code_verifier, did, handle, pds_url, auth_server, return_url, frontend_url, expires_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
INSERT INTO oauth_state (state, code_verifier, did, handle, pds_url, auth_server, return_url, frontend_url, cli_port, expires_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`
)
.bind(
Expand All @@ -307,6 +307,7 @@ export async function storeOAuthState(env: Env, state: string, data: OAuthState)
data.authServer,
data.returnUrl || null,
data.frontendUrl,
data.cliPort || null,
expiresAt
)
.run();
Expand All @@ -324,6 +325,7 @@ export async function getOAuthState(env: Env, state: string): Promise<OAuthState
auth_server: string;
return_url: string | null;
frontend_url: string | null;
cli_port: number | null;
}>();

if (!row) return null;
Expand All @@ -336,6 +338,7 @@ export async function getOAuthState(env: Env, state: string): Promise<OAuthState
authServer: row.auth_server,
returnUrl: row.return_url || undefined,
frontendUrl: row.frontend_url || '',
cliPort: row.cli_port || undefined,
};
}

Expand Down
1 change: 1 addition & 0 deletions backend/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ export interface OAuthState {
authServer: string;
returnUrl?: string;
frontendUrl: string;
cliPort?: number;
}

export interface FeedItem {
Expand Down
2 changes: 2 additions & 0 deletions cli/.prettierignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
dist/
node_modules/
6 changes: 6 additions & 0 deletions cli/.prettierrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"useTabs": false,
"singleQuote": true,
"trailingComma": "es5",
"printWidth": 100
}
164 changes: 164 additions & 0 deletions cli/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
# Skyreader CLI

Read and manage RSS feeds from the terminal. Designed for power users and AI agents (like Claude Code).

## Install

```bash
cd cli
npm install
```

Run commands with `npx tsx src/index.ts <command>` during development, or build and use the `skyreader` binary:

```bash
npm run build
node dist/index.js <command>
```

## Authentication

The CLI authenticates via your Bluesky account using OAuth. Login opens your browser to complete the flow.

```bash
# Login (production)
npx tsx src/index.ts login --handle you.bsky.social

# Login (local dev)
npx tsx src/index.ts login --handle you.bsky.social --server http://127.0.0.1:8787
```

Your session is stored in `~/.config/skyreader/config.json`.

Verify your session:

```bash
npx tsx src/index.ts whoami
```

## Commands

### `whoami`

Show current user info.

```bash
skyreader whoami
skyreader whoami --json
```

### `subscriptions`

List your feed subscriptions.

```bash
skyreader subscriptions
skyreader subscriptions --json
```

### `feeds`

Fetch articles from a single feed or all subscriptions.

```bash
# Single feed
skyreader feeds https://example.com/rss

# All subscribed feeds
skyreader feeds --all

# Limit articles per feed
skyreader feeds --all --limit 5

# Include article content
skyreader feeds --all --content

# Only show unread articles
skyreader feeds --all --unread

# Only show articles published after a date
skyreader feeds --all --since 2024-01-01
skyreader feeds --all --since "3 days ago"
skyreader feeds --all --since yesterday

# Combine filters
skyreader feeds --all --unread --since "1 week ago"

# JSON output (includes all fields)
skyreader feeds --all --json
```

### `saved`

List saved articles.

```bash
skyreader saved
skyreader saved --json
```

## Output Formats

By default, commands output human-readable tables and lists. Add `--json` to any command for structured JSON output, useful for piping to other tools:

```bash
# Get all article titles
skyreader feeds --all --json | jq '.feeds[].items[].title'

# Get saved article URLs
skyreader saved --json | jq '.[].url'

# Count articles per feed
skyreader feeds --all --json | jq '.feeds | to_entries[] | {feed: .key, count: (.value.items | length)}'
```

## AI Agent Usage

The CLI is designed to work well with AI agents. Use `--json` for structured output that's easy to parse:

```bash
# Fetch and summarize recent articles
skyreader feeds --all --limit 5 --json

# Get full article content for analysis
skyreader feeds https://example.com/rss --content --json

# Check subscriptions
skyreader subscriptions --json
```

## Configuration

Config is stored at `~/.config/skyreader/config.json`:

```json
{
"server": "https://api.skyreader.app",
"sessionId": "...",
"handle": "you.bsky.social"
}
```

The `--server` flag on `login` sets the backend URL. This is useful for local development against `http://127.0.0.1:8787`.

## Exit Codes

| Code | Meaning |
| ---- | ----------------------------------------- |
| 0 | Success |
| 1 | General error |
| 2 | Not authenticated (run `skyreader login`) |

## Local Development

Prerequisites: the backend must be running via `./scripts/dev-local.sh` from the repo root.

```bash
# Start the backend + frontend
cd .. && ./scripts/dev-local.sh

# In another terminal, use the CLI
cd cli
npx tsx src/index.ts login --handle you.bsky.social --server http://127.0.0.1:8787
npx tsx src/index.ts feeds --all --limit 3
```
Loading