···160160161161## Health Checks & Monitoring
162162163163-- `GET /api/feed?limit=1` ensures the server can read from SQLite.
163163+- `GET /status` – lightweight JSON (`status`, total items, counts per `source_kind`). Ideal for load balancer health probes.
164164+- `GET /api/feed?limit=1` ensures the server can read from SQLite and return real data.
164165- `GET /api/item/{id}` is handy for debugging a specific record.
165165-- Consider adding nginx/Caddy health check endpoints (`/healthz`) that proxy to `/api/feed?limit=1` and monitor via your platform.
166166+- Consider wiring `/status` into nginx/Caddy health checks (`/healthz`) or your platform’s monitoring agents.
166167167168## Security Tips
168169
+29-3
README.md
···1111 - **Bluesky** via AT Protocol
1212 - **Leaflet** publications via RSS feeds
1313- Local SQLite storage with full-text search
1414-- Flexible filtering and querying
1515-- Self-hostable or serverless (Cloudflare Workers)
1414+- Flexible filtering and querying via `pai list` / `pai export`
1515+- Self-hostable HTTP API (`pai serve` exposes `/api/feed`, `/api/item/{id}`, and `/status`)
1616+- Cloudflare Worker deployment path (D1) for serverless setups
16171718## Quick Start
1819···3637pai db-check
3738```
38394040+<details>
4141+<summary>For server mode, run the built-in HTTP server against your SQLite database:</summary>
4242+4343+<br>
4444+4545+```bash
4646+pai serve -d /var/lib/pai/pai.db -a 127.0.0.1:8080
4747+```
4848+4949+Endpoints:
5050+5151+- `GET /api/feed` – list newest items (supports `source_kind`, `source_id`, `limit`, `since`, `q`)
5252+- `GET /api/item/{id}` – fetch a single item
5353+- `GET /status` – health/status summary (total items, counts per source)
5454+5555+For reverse-proxy examples (nginx, Caddy, Docker), see [DEPLOYMENT.md](./DEPLOYMENT.md).
5656+5757+</details>
5858+3959## Configuration
40604161Configuration is loaded from `$XDG_CONFIG_HOME/pai/config.toml` or `$HOME/.config/pai/config.toml`.
42624363See [config.example.toml](./config.example.toml) for a complete example with all available options.
6464+6565+## Documentation
6666+6767+- CLI synopsis: `pai -h`, `pai <command> -h`, or `pai man` for the generated `pai(1)` page.
6868+- Database schema and config reference: [config.example.toml](./config.example.toml).
6969+- Deployment topologies: [DEPLOYMENT.md](./DEPLOYMENT.md).
44704571## Architecture
4672···207233208234## License
209235210210-See [LICENSE file](./LICENSE) for details.
236236+See [LICENSE](./LICENSE)
+8
cli/Cargo.toml
···1818serde = { version = "1.0", features = ["derive"] }
1919axum = "0.7"
2020tokio = { version = "1.40", features = ["macros", "rt-multi-thread", "signal"] }
2121+2222+[dev-dependencies]
2323+tempfile = "3.13"
2424+2525+[build-dependencies]
2626+clap = { version = "4.5", features = ["derive"] }
2727+clap_mangen = "0.2"
2828+pai-core = { path = "../core" }
+26
cli/build.rs
···11+use std::{env, fs, path::PathBuf};
22+33+fn main() {
44+ println!("cargo:rerun-if-changed=src/app.rs");
55+ println!("cargo:rerun-if-changed=src/main.rs");
66+77+ let out_dir = PathBuf::from(env::var_os("OUT_DIR").expect("OUT_DIR not set by Cargo build script environment"));
88+ let man_path = out_dir.join("pai.1");
99+1010+ let man = build_manpage();
1111+ fs::write(&man_path, man).expect("failed to write manpage");
1212+ println!("cargo:rustc-env=PAI_MAN_PAGE={}", man_path.display());
1313+}
1414+1515+#[path = "src/app.rs"]
1616+mod app;
1717+1818+fn build_manpage() -> Vec<u8> {
1919+ use clap::CommandFactory;
2020+2121+ let cmd = app::Cli::command();
2222+ let man = clap_mangen::Man::new(cmd);
2323+ let mut buffer = Vec::new();
2424+ man.render(&mut buffer).expect("failed to render man page");
2525+ buffer
2626+}
+112
cli/src/app.rs
···11+use clap::{Parser, Subcommand};
22+use pai_core::SourceKind;
33+use std::path::PathBuf;
44+55+/// Personal Activity Index - POSIX-style CLI for content aggregation
66+#[derive(Parser, Debug)]
77+#[command(name = "pai")]
88+#[command(version, about, long_about = None)]
99+pub struct Cli {
1010+ /// Set configuration directory
1111+ #[arg(short = 'C', value_name = "DIR", global = true)]
1212+ pub config_dir: Option<PathBuf>,
1313+1414+ /// Path to SQLite database file
1515+ #[arg(short = 'd', value_name = "PATH", global = true)]
1616+ pub db_path: Option<PathBuf>,
1717+1818+ #[command(subcommand)]
1919+ pub command: Commands,
2020+}
2121+2222+#[derive(Parser, Debug)]
2323+pub struct ExportOpts {
2424+ /// Filter by source kind
2525+ #[arg(short = 'k', value_name = "KIND")]
2626+ pub kind: Option<SourceKind>,
2727+2828+ /// Filter by specific source ID
2929+ #[arg(short = 'S', value_name = "ID")]
3030+ pub source_id: Option<String>,
3131+3232+ /// Maximum number of items
3333+ #[arg(short = 'n', value_name = "NUMBER")]
3434+ pub limit: Option<usize>,
3535+3636+ /// Only items published at or after this time
3737+ #[arg(short = 's', value_name = "TIME")]
3838+ pub since: Option<String>,
3939+4040+ /// Filter items by substring
4141+ #[arg(short = 'q', value_name = "PATTERN")]
4242+ pub query: Option<String>,
4343+4444+ /// Output format
4545+ #[arg(short = 'f', value_name = "FORMAT", default_value = "json")]
4646+ pub format: String,
4747+4848+ /// Output file (default: stdout)
4949+ #[arg(short = 'o', value_name = "FILE")]
5050+ pub output: Option<PathBuf>,
5151+}
5252+5353+#[derive(Subcommand, Debug)]
5454+pub enum Commands {
5555+ /// Fetch and store content from configured sources
5656+ Sync {
5757+ /// Sync all configured sources (default)
5858+ #[arg(short = 'a')]
5959+ all: bool,
6060+6161+ /// Sync only a particular source kind
6262+ #[arg(short = 'k', value_name = "KIND")]
6363+ kind: Option<SourceKind>,
6464+6565+ /// Sync only a specific source instance
6666+ #[arg(short = 'S', value_name = "ID")]
6767+ source_id: Option<String>,
6868+ },
6969+7070+ /// Inspect stored items
7171+ List {
7272+ /// Filter by source kind
7373+ #[arg(short = 'k', value_name = "KIND")]
7474+ kind: Option<SourceKind>,
7575+7676+ /// Filter by specific source ID
7777+ #[arg(short = 'S', value_name = "ID")]
7878+ source_id: Option<String>,
7979+8080+ /// Maximum number of items to display
8181+ #[arg(short = 'n', value_name = "NUMBER", default_value = "20")]
8282+ limit: usize,
8383+8484+ /// Only show items published at or after this time
8585+ #[arg(short = 's', value_name = "TIME")]
8686+ since: Option<String>,
8787+8888+ /// Filter items by substring in title/summary
8989+ #[arg(short = 'q', value_name = "PATTERN")]
9090+ query: Option<String>,
9191+ },
9292+9393+ /// Produce feeds or export files
9494+ Export(ExportOpts),
9595+9696+ /// Self-host HTTP API
9797+ Serve {
9898+ /// Address to bind HTTP server to
9999+ #[arg(short = 'a', value_name = "ADDRESS", default_value = "127.0.0.1:8080")]
100100+ address: String,
101101+ },
102102+103103+ /// Verify database schema and print statistics
104104+ DbCheck,
105105+106106+ /// Initialize configuration file
107107+ Init {
108108+ /// Force overwrite existing config
109109+ #[arg(short = 'f')]
110110+ force: bool,
111111+ },
112112+}
+7-114
cli/src/main.rs
···11+mod app;
12mod paths;
23mod server;
34mod storage;
4566+use app::{Cli, Commands, ExportOpts};
57use chrono::{DateTime, Duration, Utc};
66-use clap::{Parser, Subcommand};
88+use clap::Parser;
79use owo_colors::OwoColorize;
810use pai_core::{Config, Item, ListFilter, PaiError, SourceKind};
911use std::fs::File;
···1214use std::str::FromStr;
1315use storage::SqliteStorage;
14161515-/// Personal Activity Index - POSIX-style CLI for content aggregation
1616-#[derive(Parser, Debug)]
1717-#[command(name = "pai")]
1818-#[command(version, about, long_about = None)]
1919-struct Cli {
2020- /// Set configuration directory
2121- #[arg(short = 'C', value_name = "DIR", global = true)]
2222- config_dir: Option<PathBuf>,
2323-2424- /// Path to SQLite database file
2525- #[arg(short = 'd', value_name = "PATH", global = true)]
2626- db_path: Option<PathBuf>,
2727-2828- #[command(subcommand)]
2929- command: Commands,
3030-}
3131-3232-#[derive(Parser, Debug)]
3333-struct ExportOpts {
3434- /// Filter by source kind
3535- #[arg(short = 'k', value_name = "KIND")]
3636- kind: Option<SourceKind>,
3737-3838- /// Filter by specific source ID
3939- #[arg(short = 'S', value_name = "ID")]
4040- source_id: Option<String>,
4141-4242- /// Maximum number of items
4343- #[arg(short = 'n', value_name = "NUMBER")]
4444- limit: Option<usize>,
4545-4646- /// Only items published at or after this time
4747- #[arg(short = 's', value_name = "TIME")]
4848- since: Option<String>,
4949-5050- /// Filter items by substring
5151- #[arg(short = 'q', value_name = "PATTERN")]
5252- query: Option<String>,
5353-5454- /// Output format
5555- #[arg(short = 'f', value_name = "FORMAT", default_value = "json")]
5656- format: String,
5757-5858- /// Output file (default: stdout)
5959- #[arg(short = 'o', value_name = "FILE")]
6060- output: Option<PathBuf>,
6161-}
6262-6363-#[derive(Subcommand, Debug)]
6464-enum Commands {
6565- /// Fetch and store content from configured sources
6666- Sync {
6767- /// Sync all configured sources (default)
6868- #[arg(short = 'a')]
6969- all: bool,
7070-7171- /// Sync only a particular source kind
7272- #[arg(short = 'k', value_name = "KIND")]
7373- kind: Option<SourceKind>,
7474-7575- /// Sync only a specific source instance
7676- #[arg(short = 'S', value_name = "ID")]
7777- source_id: Option<String>,
7878- },
7979-8080- /// Inspect stored items
8181- List {
8282- /// Filter by source kind
8383- #[arg(short = 'k', value_name = "KIND")]
8484- kind: Option<SourceKind>,
8585-8686- /// Filter by specific source ID
8787- #[arg(short = 'S', value_name = "ID")]
8888- source_id: Option<String>,
8989-9090- /// Maximum number of items to display
9191- #[arg(short = 'n', value_name = "NUMBER", default_value = "20")]
9292- limit: usize,
9393-9494- /// Only show items published at or after this time
9595- #[arg(short = 's', value_name = "TIME")]
9696- since: Option<String>,
9797-9898- /// Filter items by substring in title/summary
9999- #[arg(short = 'q', value_name = "PATTERN")]
100100- query: Option<String>,
101101- },
102102-103103- /// Produce feeds or export files
104104- Export(ExportOpts),
105105-106106- /// Self-host HTTP API
107107- Serve {
108108- /// Address to bind HTTP server to
109109- #[arg(short = 'a', value_name = "ADDRESS", default_value = "127.0.0.1:8080")]
110110- address: String,
111111- },
112112-113113- /// Verify database schema and print statistics
114114- DbCheck,
115115-116116- /// Initialize configuration file
117117- Init {
118118- /// Force overwrite existing config
119119- #[arg(short = 'f')]
120120- force: bool,
121121- },
122122-}
1717+const PUBLISHED_WIDTH: usize = 19;
1818+const KIND_WIDTH: usize = 9;
1919+const SOURCE_WIDTH: usize = 24;
2020+const TITLE_WIDTH: usize = 60;
1232112422fn main() {
12523 let cli = Cli::parse();
···527425}
528426529427fn write_items_table<W: Write>(items: &[Item], writer: &mut W) -> io::Result<()> {
530530- const PUBLISHED_WIDTH: usize = 19;
531531- const KIND_WIDTH: usize = 9;
532532- const SOURCE_WIDTH: usize = 24;
533533- const TITLE_WIDTH: usize = 60;
534534-535428 let header = format!(
536429 "| {published:<pub_width$} | {kind:<kind_width$} | {source:<source_width$} | {title:<title_width$} |",
537430 published = "Published",