diff --git a/.gitignore b/.gitignore
index 908a233..265c637 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,8 +1,9 @@
-dist
-repos
+/dist
+/repos
+/target
+/CLAUDE.md
+/.claude
node_modules
package-lock.json
-CLAUDE.md
-.claude
+Cargo.lock
.env
-target
diff --git a/Cargo.toml b/Cargo.toml
index 5254d46..d4485b1 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -2,4 +2,22 @@
name = "ailog"
version = "0.2.0"
edition = "2021"
+description = "ATProto blog CLI"
+authors = ["syui"]
+homepage = "https://syui.ai"
+repository = "https://git.syui.ai/ai/log"
+[[bin]]
+name = "ailog"
+path = "src/main.rs"
+
+[dependencies]
+clap = { version = "4.5", features = ["derive"] }
+serde = { version = "1.0", features = ["derive"] }
+serde_json = "1.0"
+reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-features = false }
+tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
+anyhow = "1.0"
+dirs = "5.0"
+chrono = { version = "0.4", features = ["serde"] }
+rand = "0.8"
diff --git a/content/did:plc:vzsvtbtbnwn22xjqhcu3vd6y/ai.syui.log.post/3mchqlshygs2s.json b/content/did:plc:vzsvtbtbnwn22xjqhcu3vd6y/ai.syui.log.post/3mchqlshygs2s.json
index 60ce66d..e38992b 100644
--- a/content/did:plc:vzsvtbtbnwn22xjqhcu3vd6y/ai.syui.log.post/3mchqlshygs2s.json
+++ b/content/did:plc:vzsvtbtbnwn22xjqhcu3vd6y/ai.syui.log.post/3mchqlshygs2s.json
@@ -1,18 +1,18 @@
{
- "uri": "at://did:plc:vzsvtbtbnwn22xjqhcu3vd6y/ai.syui.log.post/3mchqlshygs2s",
"cid": "bafyreielgn743kg5xotfj5x53edl25vkbbd2d6v7s3tydyyjsvczcluyme",
+ "uri": "at://did:plc:vzsvtbtbnwn22xjqhcu3vd6y/ai.syui.log.post/3mchqlshygs2s",
"value": {
- "cid": "bafyreidymanu2xk4ftmvfdna3j7ixyijc37s6h3aytstuqgzatgjl4tp7e",
- "uri": "at://did:plc:vzsvtbtbnwn22xjqhcu3vd6y/ai.syui.log.post/3mchqlshygs2s",
"$type": "ai.syui.log.post",
- "title": "ailogを作り直した",
+ "cid": "bafyreidymanu2xk4ftmvfdna3j7ixyijc37s6h3aytstuqgzatgjl4tp7e",
"content": "## ailogとは\n\natprotoと連携するサイトジェネレータ。\n\n## ailogの使い方\n\n```sh\n$ git clone https://git.syui.ai/ai/log\n$ cd log\n$ cat public/config.json\n{\n \"title\": \"syui.ai\",\n \"handle\": \"syui.syui.ai\",\n \"collection\": \"ai.syui.log.post\",\n \"network\": \"syu.is\",\n \"color\": \"#0066cc\",\n \"siteUrl\": \"https://syui.ai\"\n}\n---\n$ npm run dev\n```\n\n## ailogのコンセプト\n\n1. at-browserを基本にする\n2. atproto oauthでログインする\n3. ログインしたアカウントで記事をポストする\n\n## ailogの追加機能\n\n1. atproto recordからjsonをdownloadすると表示速度が上がる(ただし更新はlocalから)\n2. コメントはurlの言及を検索して表示\n\n```sh\n$ npm run fetch\n$ npm run generate\n```",
"createdAt": "2026-01-15T13:59:52.367Z",
+ "title": "ailogを作り直した",
"translations": {
"en": {
- "title": "recreated ailog",
- "content": "## What is ailog?\n\nA site generator that integrates with the atproto framework.\n\n## How to Use ailog\n\n```sh\n$ git clone https://git.syui.ai/ai/log\n$ cd log\n$ cat public/config.json\n{\n \"title\": \"syui.ai\",\n \"handle\": \"syui.syui.ai\",\n \"collection\": \"ai.syui.log.post\",\n \"network\": \"syu.is\",\n \"color\": \"#0066cc\",\n \"siteUrl\": \"https://syui.ai\"\n}\n---\n$ npm run dev\n```\n\n## ailog's Concept\n\n1. Based on at-browser as its foundation\n2. Authentication via atproto oAuth\n3. Post articles using the logged-in account\n\n## Additional Features of ailog\n\n1. Downloading JSON from atproto record improves display speed (though updates still come from local storage)\n2. Comments are displayed by searching for URL mentions\n\n```sh\n$ npm run fetch\n$ npm run generate\n```"
+ "content": "## What is ailog?\n\nA site generator that integrates with the atproto framework.\n\n## How to Use ailog\n\n```sh\n$ git clone https://git.syui.ai/ai/log\n$ cd log\n$ cat public/config.json\n{\n \"title\": \"syui.ai\",\n \"handle\": \"syui.syui.ai\",\n \"collection\": \"ai.syui.log.post\",\n \"network\": \"syu.is\",\n \"color\": \"#0066cc\",\n \"siteUrl\": \"https://syui.ai\"\n}\n---\n$ npm run dev\n```\n\n## ailog's Concept\n\n1. Based on at-browser as its foundation\n2. Authentication via atproto oAuth\n3. Post articles using the logged-in account\n\n## Additional Features of ailog\n\n1. Downloading JSON from atproto record improves display speed (though updates still come from local storage)\n2. Comments are displayed by searching for URL mentions\n\n```sh\n$ npm run fetch\n$ npm run generate\n```",
+ "title": "recreated ailog"
}
- }
+ },
+ "uri": "at://did:plc:vzsvtbtbnwn22xjqhcu3vd6y/ai.syui.log.post/3mchqlshygs2s"
}
-}
+}
\ No newline at end of file
diff --git a/content/did:plc:vzsvtbtbnwn22xjqhcu3vd6y/app.bsky.actor.profile/self.json b/content/did:plc:vzsvtbtbnwn22xjqhcu3vd6y/app.bsky.actor.profile/self.json
index 8a3ebf5..b62a032 100644
--- a/content/did:plc:vzsvtbtbnwn22xjqhcu3vd6y/app.bsky.actor.profile/self.json
+++ b/content/did:plc:vzsvtbtbnwn22xjqhcu3vd6y/app.bsky.actor.profile/self.json
@@ -1,18 +1,18 @@
{
- "uri": "at://did:plc:vzsvtbtbnwn22xjqhcu3vd6y/app.bsky.actor.profile/self",
"cid": "bafyreihlch2vdee6wpydo2bwap7nyzszjz6focbtxikz7zljcejxz27npy",
+ "uri": "at://did:plc:vzsvtbtbnwn22xjqhcu3vd6y/app.bsky.actor.profile/self",
"value": {
"$type": "app.bsky.actor.profile",
"avatar": {
"$type": "blob",
+ "mimeType": "image/jpeg",
"ref": {
"$link": "bafkreigta4pf5h7uvx6jpfcm3d6aeq4g3qpsiqjdoeytnutwp6vwc2yo7u"
},
- "mimeType": "image/jpeg",
"size": 166370
},
"createdAt": "2025-09-19T06:17:42Z",
"description": "",
"displayName": "syui"
}
-}
+}
\ No newline at end of file
diff --git a/content/did:plc:vzsvtbtbnwn22xjqhcu3vd6y/describe.json b/content/did:plc:vzsvtbtbnwn22xjqhcu3vd6y/describe.json
index 2e46361..70d3c25 100644
--- a/content/did:plc:vzsvtbtbnwn22xjqhcu3vd6y/describe.json
+++ b/content/did:plc:vzsvtbtbnwn22xjqhcu3vd6y/describe.json
@@ -1,39 +1,13 @@
{
- "handle": "syui.syui.ai",
- "did": "did:plc:vzsvtbtbnwn22xjqhcu3vd6y",
- "didDoc": {
- "@context": [
- "https://www.w3.org/ns/did/v1",
- "https://w3id.org/security/multikey/v1",
- "https://w3id.org/security/suites/secp256k1-2019/v1"
- ],
- "id": "did:plc:vzsvtbtbnwn22xjqhcu3vd6y",
- "alsoKnownAs": [
- "at://syui.syui.ai"
- ],
- "verificationMethod": [
- {
- "id": "did:plc:vzsvtbtbnwn22xjqhcu3vd6y#atproto",
- "type": "Multikey",
- "controller": "did:plc:vzsvtbtbnwn22xjqhcu3vd6y",
- "publicKeyMultibase": "zQ3shZj81oA4A9CmUQgYUv97nFdd7m5qNaRMyG16XZixytTmQ"
- }
- ],
- "service": [
- {
- "id": "#atproto_pds",
- "type": "AtprotoPersonalDataServer",
- "serviceEndpoint": "https://syu.is"
- }
- ]
- },
"collections": [
"ai.syui.log.post",
"app.bsky.actor.profile",
"app.bsky.feed.post",
"app.bsky.feed.repost",
"app.bsky.graph.follow",
- "chat.bsky.actor.declaration"
+ "chat.bsky.actor.declaration",
+ "com.atproto.lexicon.schema"
],
- "handleIsCorrect": true
-}
+ "did": "did:plc:vzsvtbtbnwn22xjqhcu3vd6y",
+ "handle": "syui.syui.ai"
+}
\ No newline at end of file
diff --git a/index.html b/index.html
new file mode 100644
index 0000000..140ef9a
--- /dev/null
+++ b/index.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+ ailog
+
+
+
+
+
+
+
diff --git a/readme.md b/readme.md
index bb09443..59b155e 100644
--- a/readme.md
+++ b/readme.md
@@ -152,13 +152,20 @@ curl "https://syu.is/xrpc/com.atproto.repo.listRecords?repo=did:plc:xxx&collecti
### Local (Static File)
```
-public/records/ai.syui.log.post/3xxx.json
+content/
+└── did:plc:xxx/
+ ├── describe.json # describeRepo (special)
+ ├── app.bsky.actor.profile/
+ │ └── self.json # {collection}/{rkey}.json
+ └── ai.syui.log.post/
+ └── 3xxx.json # {collection}/{rkey}.json
```
```json
+// content/did:plc:xxx/ai.syui.log.post/3xxx.json
{
"uri": "at://did:plc:xxx/ai.syui.log.post/3xxx",
- "cid": "local",
+ "cid": "bafyrei...",
"value": {
"title": "Hello World",
"content": "# Hello\n\nThis is my post.",
@@ -167,13 +174,23 @@ public/records/ai.syui.log.post/3xxx.json
}
```
+### ATProto API Reference
+
+| API | Path | Description |
+|-----|------|-------------|
+| getRecord | `/xrpc/com.atproto.repo.getRecord` | Get single record |
+| listRecords | `/xrpc/com.atproto.repo.listRecords` | List records in collection |
+| describeRepo | `/xrpc/com.atproto.repo.describeRepo` | Get repo info + collections list |
+
+See: [com.atproto.repo.describeRepo](https://docs.bsky.app/docs/api/com-atproto-repo-describe-repo)
+
### Resolution Strategy
```
at-browser
│
├── admin (config.json user)
- │ ├── 1. Check local: /records/{collection}/{rkey}.json
+ │ ├── 1. Check local: /content/{did}/{collection}/{rkey}.json
│ └── 2. Fallback to remote: PDS API
│
└── user (/@handle)
diff --git a/src/auth.rs b/src/auth.rs
new file mode 100644
index 0000000..45304d7
--- /dev/null
+++ b/src/auth.rs
@@ -0,0 +1,96 @@
+use anyhow::{Context, Result};
+use serde::{Deserialize, Serialize};
+
+use crate::token::{self, Session};
+
+#[derive(Debug, Serialize)]
+struct CreateSessionRequest {
+ identifier: String,
+ password: String,
+}
+
+#[derive(Debug, Deserialize)]
+#[serde(rename_all = "camelCase")]
+struct CreateSessionResponse {
+ did: String,
+ handle: String,
+ access_jwt: String,
+ refresh_jwt: String,
+}
+
+/// Login to ATProto PDS
+pub async fn login(handle: &str, password: &str, pds: &str) -> Result<()> {
+ let client = reqwest::Client::new();
+ let url = format!("https://{}/xrpc/com.atproto.server.createSession", pds);
+
+ let req = CreateSessionRequest {
+ identifier: handle.to_string(),
+ password: password.to_string(),
+ };
+
+ println!("Logging in to {} as {}...", pds, handle);
+
+ let res = client
+ .post(&url)
+ .json(&req)
+ .send()
+ .await
+ .context("Failed to send login request")?;
+
+ if !res.status().is_success() {
+ let status = res.status();
+ let body = res.text().await.unwrap_or_default();
+ anyhow::bail!("Login failed: {} - {}", status, body);
+ }
+
+ let session_res: CreateSessionResponse = res.json().await?;
+
+ let session = Session {
+ did: session_res.did,
+ handle: session_res.handle,
+ access_jwt: session_res.access_jwt,
+ refresh_jwt: session_res.refresh_jwt,
+ pds: Some(pds.to_string()),
+ };
+
+ token::save_session(&session)?;
+ println!("Logged in as {} ({})", session.handle, session.did);
+
+ Ok(())
+}
+
+/// Refresh access token
+pub async fn refresh_session() -> Result {
+ let session = token::load_session()?;
+ let pds = session.pds.as_deref().unwrap_or("bsky.social");
+
+ let client = reqwest::Client::new();
+ let url = format!("https://{}/xrpc/com.atproto.server.refreshSession", pds);
+
+ let res = client
+ .post(&url)
+ .header("Authorization", format!("Bearer {}", session.refresh_jwt))
+ .send()
+ .await
+ .context("Failed to refresh session")?;
+
+ if !res.status().is_success() {
+ let status = res.status();
+ let body = res.text().await.unwrap_or_default();
+ anyhow::bail!("Refresh failed: {} - {}. Try logging in again.", status, body);
+ }
+
+ let new_session: CreateSessionResponse = res.json().await?;
+
+ let session = Session {
+ did: new_session.did,
+ handle: new_session.handle,
+ access_jwt: new_session.access_jwt,
+ refresh_jwt: new_session.refresh_jwt,
+ pds: Some(pds.to_string()),
+ };
+
+ token::save_session(&session)?;
+
+ Ok(session)
+}
diff --git a/src/lib/api.ts b/src/lib/api.ts
new file mode 100644
index 0000000..a530e15
--- /dev/null
+++ b/src/lib/api.ts
@@ -0,0 +1,272 @@
+// Types matching ATProto record format
+export interface Config {
+ title: string
+ handle: string
+ collection: string
+ network: string
+ color: string
+ siteUrl: string
+}
+
+export interface Networks {
+ [domain: string]: {
+ plc: string
+ bsky: string
+ web: string
+ }
+}
+
+export interface Links {
+ footer: Array<{ title: string; url: string }>
+}
+
+export interface DescribeRepo {
+ did: string
+ handle: string
+ collections: string[]
+}
+
+export interface Profile {
+ cid: string
+ uri: string
+ value: {
+ $type: string
+ avatar?: {
+ $type: string
+ mimeType: string
+ ref: { $link: string }
+ size: number
+ }
+ displayName?: string
+ description?: string
+ createdAt?: string
+ }
+}
+
+export interface Post {
+ cid: string
+ uri: string
+ value: {
+ $type: string
+ title: string
+ content: string
+ createdAt: string
+ lang?: string
+ translations?: {
+ [lang: string]: {
+ title: string
+ content: string
+ }
+ }
+ }
+}
+
+// Cache for loaded data
+let configCache: Config | null = null
+let networksCache: Networks | null = null
+let linksCache: Links | null = null
+
+// Load config.json
+export async function getConfig(): Promise {
+ if (configCache) return configCache
+ const res = await fetch('/config.json')
+ configCache = await res.json()
+ return configCache!
+}
+
+// Load networks.json
+export async function getNetworks(): Promise {
+ if (networksCache) return networksCache
+ const res = await fetch('/networks.json')
+ networksCache = await res.json()
+ return networksCache!
+}
+
+// Load links.json
+export async function getLinks(): Promise {
+ if (linksCache) return linksCache
+ const res = await fetch('/links.json')
+ linksCache = await res.json()
+ return linksCache!
+}
+
+// Resolve handle to DID using local describe.json or remote API
+export async function resolveHandle(handle: string): Promise {
+ const config = await getConfig()
+ const networks = await getNetworks()
+ const network = networks[config.network]
+
+ // Try remote resolution
+ try {
+ const res = await fetch(`${network.bsky}/xrpc/com.atproto.identity.resolveHandle?handle=${handle}`)
+ if (res.ok) {
+ const data = await res.json()
+ return data.did
+ }
+ } catch {
+ // Fall back to searching local content
+ }
+
+ return null
+}
+
+// Get DID from local content directory
+export async function getLocalDid(): Promise {
+ const config = await getConfig()
+
+ // Try to resolve via API first
+ const did = await resolveHandle(config.handle)
+ if (did) return did
+
+ return null
+}
+
+// Load describe.json for a DID
+export async function getDescribe(did: string): Promise {
+ try {
+ const res = await fetch(`/content/${did}/describe.json`)
+ if (res.ok) {
+ return await res.json()
+ }
+ } catch {
+ // File not found
+ }
+ return null
+}
+
+// Load profile for a DID
+export async function getProfile(did: string): Promise {
+ try {
+ const res = await fetch(`/content/${did}/app.bsky.actor.profile/self.json`)
+ if (res.ok) {
+ return await res.json()
+ }
+ } catch {
+ // File not found
+ }
+ return null
+}
+
+// Get avatar URL from profile
+export async function getAvatarUrl(did: string, profile: Profile): Promise {
+ if (!profile.value.avatar) return null
+
+ const config = await getConfig()
+ const networks = await getNetworks()
+ const network = networks[config.network]
+
+ // Get PDS endpoint for this DID
+ try {
+ const plcRes = await fetch(`${network.plc}/${did}`)
+ if (plcRes.ok) {
+ const didDoc = await plcRes.json()
+ const pds = didDoc.service?.find((s: { type: string }) => s.type === 'AtprotoPersonalDataServer')?.serviceEndpoint
+ if (pds) {
+ return `${pds}/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${profile.value.avatar.ref.$link}`
+ }
+ }
+ } catch {
+ // Fall back to bsky.social
+ }
+
+ return `https://bsky.social/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${profile.value.avatar.ref.$link}`
+}
+
+// List all posts from a collection
+export async function listPosts(did: string, collection: string): Promise {
+ const posts: Post[] = []
+
+ // Try to load index.json which lists all rkeys
+ try {
+ const indexRes = await fetch(`/content/${did}/${collection}/index.json`)
+ if (indexRes.ok) {
+ const index: string[] = await indexRes.json()
+ for (const rkey of index) {
+ const postRes = await fetch(`/content/${did}/${collection}/${rkey}.json`)
+ if (postRes.ok) {
+ posts.push(await postRes.json())
+ }
+ }
+ return posts.sort((a, b) =>
+ new Date(b.value.createdAt).getTime() - new Date(a.value.createdAt).getTime()
+ )
+ }
+ } catch {
+ // No index file
+ }
+
+ // Fallback: load from remote API
+ const config = await getConfig()
+ const networks = await getNetworks()
+ const network = networks[config.network]
+
+ try {
+ // Get PDS endpoint
+ const plcRes = await fetch(`${network.plc}/${did}`)
+ if (plcRes.ok) {
+ const didDoc = await plcRes.json()
+ const pds = didDoc.service?.find((s: { type: string }) => s.type === 'AtprotoPersonalDataServer')?.serviceEndpoint
+ if (pds) {
+ const recordsRes = await fetch(`${pds}/xrpc/com.atproto.repo.listRecords?repo=${did}&collection=${collection}&limit=100`)
+ if (recordsRes.ok) {
+ const data = await recordsRes.json()
+ return data.records.map((r: { uri: string; cid: string; value: Post['value'] }) => ({
+ uri: r.uri,
+ cid: r.cid,
+ value: r.value
+ })).sort((a: Post, b: Post) =>
+ new Date(b.value.createdAt).getTime() - new Date(a.value.createdAt).getTime()
+ )
+ }
+ }
+ }
+ } catch {
+ // Remote API failed
+ }
+
+ return posts
+}
+
+// Get a single post by rkey
+export async function getPost(did: string, collection: string, rkey: string): Promise {
+ // Try local first
+ try {
+ const res = await fetch(`/content/${did}/${collection}/${rkey}.json`)
+ if (res.ok) {
+ return await res.json()
+ }
+ } catch {
+ // File not found
+ }
+
+ // Fallback: load from remote API
+ const config = await getConfig()
+ const networks = await getNetworks()
+ const network = networks[config.network]
+
+ try {
+ const plcRes = await fetch(`${network.plc}/${did}`)
+ if (plcRes.ok) {
+ const didDoc = await plcRes.json()
+ const pds = didDoc.service?.find((s: { type: string }) => s.type === 'AtprotoPersonalDataServer')?.serviceEndpoint
+ if (pds) {
+ const recordRes = await fetch(`${pds}/xrpc/com.atproto.repo.getRecord?repo=${did}&collection=${collection}&rkey=${rkey}`)
+ if (recordRes.ok) {
+ return await recordRes.json()
+ }
+ }
+ }
+ } catch {
+ // Remote API failed
+ }
+
+ return null
+}
+
+// Get profile link URL
+export async function getProfileUrl(did: string): Promise {
+ const config = await getConfig()
+ const networks = await getNetworks()
+ const network = networks[config.network]
+ return `${network.web}/profile/${did}`
+}
diff --git a/src/main.rs b/src/main.rs
new file mode 100644
index 0000000..a7e965f
--- /dev/null
+++ b/src/main.rs
@@ -0,0 +1,107 @@
+mod auth;
+mod token;
+mod post;
+
+use anyhow::Result;
+use clap::{Parser, Subcommand};
+
+#[derive(Parser)]
+#[command(name = "ailog")]
+#[command(about = "ATProto blog CLI")]
+struct Cli {
+ #[command(subcommand)]
+ command: Commands,
+}
+
+#[derive(Subcommand)]
+enum Commands {
+ /// Login to ATProto PDS
+ #[command(alias = "l")]
+ Login {
+ /// Handle (e.g., user.bsky.social)
+ handle: String,
+ /// Password
+ #[arg(short, long)]
+ password: String,
+ /// PDS server
+ #[arg(short, long, default_value = "bsky.social")]
+ server: String,
+ },
+
+ /// Update lexicon schema
+ Lexicon {
+ /// Lexicon JSON file
+ file: String,
+ },
+
+ /// Post a record
+ #[command(alias = "p")]
+ Post {
+ /// Record JSON file
+ file: String,
+ /// Collection (e.g., ai.syui.log.post)
+ #[arg(short, long)]
+ collection: String,
+ /// Record key (auto-generated if not provided)
+ #[arg(short, long)]
+ rkey: Option,
+ },
+
+ /// Get records from collection
+ #[command(alias = "g")]
+ Get {
+ /// Collection (e.g., ai.syui.log.post)
+ #[arg(short, long)]
+ collection: String,
+ /// Limit
+ #[arg(short, long, default_value = "10")]
+ limit: u32,
+ },
+
+ /// Delete a record
+ #[command(alias = "d")]
+ Delete {
+ /// Collection (e.g., ai.syui.log.post)
+ #[arg(short, long)]
+ collection: String,
+ /// Record key
+ #[arg(short, long)]
+ rkey: String,
+ },
+
+ /// Sync PDS data to local content directory
+ #[command(alias = "s")]
+ Sync {
+ /// Output directory
+ #[arg(short, long, default_value = "content")]
+ output: String,
+ },
+}
+
+#[tokio::main]
+async fn main() -> Result<()> {
+ let cli = Cli::parse();
+
+ match cli.command {
+ Commands::Login { handle, password, server } => {
+ auth::login(&handle, &password, &server).await?;
+ }
+ Commands::Lexicon { file } => {
+ post::put_lexicon(&file).await?;
+ }
+ Commands::Post { file, collection, rkey } => {
+ post::put_record(&file, &collection, rkey.as_deref()).await?;
+ }
+ Commands::Get { collection, limit } => {
+ post::get_records(&collection, limit).await?;
+ }
+ Commands::Delete { collection, rkey } => {
+ post::delete_record(&collection, &rkey).await?;
+ }
+ Commands::Sync { output } => {
+ post::sync_to_local(&output).await?;
+ }
+ }
+
+ Ok(())
+}
diff --git a/src/post.rs b/src/post.rs
new file mode 100644
index 0000000..f560939
--- /dev/null
+++ b/src/post.rs
@@ -0,0 +1,340 @@
+use anyhow::{Context, Result};
+use rand::Rng;
+use serde::{Deserialize, Serialize};
+use serde_json::Value;
+use std::fs;
+
+use crate::auth;
+
+#[derive(Debug, Serialize)]
+struct PutRecordRequest {
+ repo: String,
+ collection: String,
+ rkey: String,
+ record: Value,
+}
+
+#[derive(Debug, Serialize)]
+struct DeleteRecordRequest {
+ repo: String,
+ collection: String,
+ rkey: String,
+}
+
+#[derive(Debug, Deserialize)]
+struct PutRecordResponse {
+ uri: String,
+ cid: String,
+}
+
+#[derive(Debug, Deserialize)]
+struct ListRecordsResponse {
+ records: Vec,
+ #[serde(default)]
+ cursor: Option,
+}
+
+#[derive(Debug, Deserialize)]
+struct Record {
+ uri: String,
+ cid: String,
+ value: Value,
+}
+
+/// Generate TID (timestamp-based ID)
+fn generate_tid() -> String {
+ const CHARSET: &[u8] = b"234567abcdefghijklmnopqrstuvwxyz";
+ let mut rng = rand::thread_rng();
+ (0..13)
+ .map(|_| {
+ let idx = rng.gen_range(0..CHARSET.len());
+ CHARSET[idx] as char
+ })
+ .collect()
+}
+
+/// Put a record to ATProto
+pub async fn put_record(file: &str, collection: &str, rkey: Option<&str>) -> Result<()> {
+ // Refresh token first
+ let session = auth::refresh_session().await?;
+ let pds = session.pds.as_deref().unwrap_or("bsky.social");
+
+ // Load record from file
+ let content = fs::read_to_string(file)
+ .with_context(|| format!("Failed to read file: {}", file))?;
+ let record: Value = serde_json::from_str(&content)?;
+
+ // Generate rkey if not provided
+ let rkey = rkey.map(|s| s.to_string()).unwrap_or_else(generate_tid);
+
+ let client = reqwest::Client::new();
+ let url = format!("https://{}/xrpc/com.atproto.repo.putRecord", pds);
+
+ let req = PutRecordRequest {
+ repo: session.did.clone(),
+ collection: collection.to_string(),
+ rkey: rkey.clone(),
+ record,
+ };
+
+ println!("Posting to {} with rkey: {}", collection, rkey);
+ println!("{}", serde_json::to_string_pretty(&req)?);
+
+ let res = client
+ .post(&url)
+ .header("Authorization", format!("Bearer {}", session.access_jwt))
+ .json(&req)
+ .send()
+ .await?;
+
+ if !res.status().is_success() {
+ let status = res.status();
+ let body = res.text().await.unwrap_or_default();
+ anyhow::bail!("Put record failed: {} - {}", status, body);
+ }
+
+ let result: PutRecordResponse = res.json().await?;
+ println!("Success!");
+ println!(" URI: {}", result.uri);
+ println!(" CID: {}", result.cid);
+
+ Ok(())
+}
+
+/// Put a lexicon schema
+pub async fn put_lexicon(file: &str) -> Result<()> {
+ // Refresh token first
+ let session = auth::refresh_session().await?;
+ let pds = session.pds.as_deref().unwrap_or("bsky.social");
+
+ // Load lexicon from file
+ let content = fs::read_to_string(file)
+ .with_context(|| format!("Failed to read file: {}", file))?;
+ let lexicon: Value = serde_json::from_str(&content)?;
+
+ // Get lexicon id for rkey
+ let lexicon_id = lexicon["id"]
+ .as_str()
+ .context("Lexicon file must have 'id' field")?
+ .to_string();
+
+ let client = reqwest::Client::new();
+ let url = format!("https://{}/xrpc/com.atproto.repo.putRecord", pds);
+
+ let req = PutRecordRequest {
+ repo: session.did.clone(),
+ collection: "com.atproto.lexicon.schema".to_string(),
+ rkey: lexicon_id.clone(),
+ record: lexicon,
+ };
+
+ println!("Putting lexicon: {}", lexicon_id);
+ println!("{}", serde_json::to_string_pretty(&req)?);
+
+ let res = client
+ .post(&url)
+ .header("Authorization", format!("Bearer {}", session.access_jwt))
+ .json(&req)
+ .send()
+ .await?;
+
+ if !res.status().is_success() {
+ let status = res.status();
+ let body = res.text().await.unwrap_or_default();
+ anyhow::bail!("Put lexicon failed: {} - {}", status, body);
+ }
+
+ let result: PutRecordResponse = res.json().await?;
+ println!("Success!");
+ println!(" URI: {}", result.uri);
+ println!(" CID: {}", result.cid);
+
+ Ok(())
+}
+
+/// Get records from a collection
+pub async fn get_records(collection: &str, limit: u32) -> Result<()> {
+ let session = auth::refresh_session().await?;
+ let pds = session.pds.as_deref().unwrap_or("bsky.social");
+
+ let client = reqwest::Client::new();
+ let url = format!(
+ "https://{}/xrpc/com.atproto.repo.listRecords?repo={}&collection={}&limit={}",
+ pds, session.did, collection, limit
+ );
+
+ let res = client
+ .get(&url)
+ .header("Authorization", format!("Bearer {}", session.access_jwt))
+ .send()
+ .await?;
+
+ if !res.status().is_success() {
+ let status = res.status();
+ let body = res.text().await.unwrap_or_default();
+ anyhow::bail!("Get records failed: {} - {}", status, body);
+ }
+
+ let result: ListRecordsResponse = res.json().await?;
+
+ println!("Found {} records in {}", result.records.len(), collection);
+ for record in &result.records {
+ println!("---");
+ println!("URI: {}", record.uri);
+ println!("CID: {}", record.cid);
+ println!("{}", serde_json::to_string_pretty(&record.value)?);
+ }
+
+ Ok(())
+}
+
+/// Delete a record
+pub async fn delete_record(collection: &str, rkey: &str) -> Result<()> {
+ let session = auth::refresh_session().await?;
+ let pds = session.pds.as_deref().unwrap_or("bsky.social");
+
+ let client = reqwest::Client::new();
+ let url = format!("https://{}/xrpc/com.atproto.repo.deleteRecord", pds);
+
+ let req = DeleteRecordRequest {
+ repo: session.did.clone(),
+ collection: collection.to_string(),
+ rkey: rkey.to_string(),
+ };
+
+ println!("Deleting {} from {}", rkey, collection);
+
+ let res = client
+ .post(&url)
+ .header("Authorization", format!("Bearer {}", session.access_jwt))
+ .json(&req)
+ .send()
+ .await?;
+
+ if !res.status().is_success() {
+ let status = res.status();
+ let body = res.text().await.unwrap_or_default();
+ anyhow::bail!("Delete failed: {} - {}", status, body);
+ }
+
+ println!("Deleted successfully");
+
+ Ok(())
+}
+
+#[derive(Debug, Deserialize)]
+struct Config {
+ handle: String,
+ #[serde(default)]
+ collection: Option,
+}
+
+#[derive(Debug, Deserialize)]
+struct DescribeRepoResponse {
+ did: String,
+ handle: String,
+ collections: Vec,
+}
+
+/// Sync PDS data to local content directory
+pub async fn sync_to_local(output: &str) -> Result<()> {
+ // Load config.json
+ let config_content = fs::read_to_string("config.json")
+ .context("config.json not found")?;
+ let config: Config = serde_json::from_str(&config_content)?;
+
+ println!("Syncing data for {}", config.handle);
+
+ let client = reqwest::Client::new();
+
+ // Resolve handle to DID and PDS
+ let resolve_url = format!(
+ "https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle={}",
+ config.handle
+ );
+ let res = client.get(&resolve_url).send().await?;
+ let resolve: serde_json::Value = res.json().await?;
+ let did = resolve["did"].as_str().context("Could not resolve handle")?;
+
+ println!("DID: {}", did);
+
+ // Get PDS from DID document
+ let plc_url = format!("https://plc.directory/{}", did);
+ let res = client.get(&plc_url).send().await?;
+ let did_doc: serde_json::Value = res.json().await?;
+ let pds = did_doc["service"]
+ .as_array()
+ .and_then(|services| {
+ services.iter().find(|s| s["type"] == "AtprotoPersonalDataServer")
+ })
+ .and_then(|s| s["serviceEndpoint"].as_str())
+ .context("Could not find PDS")?;
+
+ println!("PDS: {}", pds);
+
+ // Create output directory
+ let did_dir = format!("{}/{}", output, did);
+ fs::create_dir_all(&did_dir)?;
+
+ // 1. Sync describeRepo
+ let describe_url = format!(
+ "{}/xrpc/com.atproto.repo.describeRepo?repo={}",
+ pds, did
+ );
+ let res = client.get(&describe_url).send().await?;
+ let describe: DescribeRepoResponse = res.json().await?;
+
+ let describe_path = format!("{}/describe.json", did_dir);
+ let describe_json = serde_json::to_string_pretty(&serde_json::json!({
+ "did": describe.did,
+ "handle": describe.handle,
+ "collections": describe.collections,
+ }))?;
+ fs::write(&describe_path, &describe_json)?;
+ println!("Saved: {}", describe_path);
+
+ // 2. Sync profile
+ let profile_url = format!(
+ "{}/xrpc/com.atproto.repo.getRecord?repo={}&collection=app.bsky.actor.profile&rkey=self",
+ pds, did
+ );
+ let res = client.get(&profile_url).send().await?;
+ if res.status().is_success() {
+ let profile: serde_json::Value = res.json().await?;
+ let profile_dir = format!("{}/app.bsky.actor.profile", did_dir);
+ fs::create_dir_all(&profile_dir)?;
+ let profile_path = format!("{}/self.json", profile_dir);
+ fs::write(&profile_path, serde_json::to_string_pretty(&profile)?)?;
+ println!("Saved: {}", profile_path);
+ }
+
+ // 3. Sync collection records (from config or default)
+ let collection = config.collection.as_deref().unwrap_or("ai.syui.log.post");
+ let records_url = format!(
+ "{}/xrpc/com.atproto.repo.listRecords?repo={}&collection={}&limit=100",
+ pds, did, collection
+ );
+ let res = client.get(&records_url).send().await?;
+ if res.status().is_success() {
+ let list: ListRecordsResponse = res.json().await?;
+ let collection_dir = format!("{}/{}", did_dir, collection);
+ fs::create_dir_all(&collection_dir)?;
+
+ for record in &list.records {
+ let rkey = record.uri.split('/').last().unwrap_or("unknown");
+ let record_path = format!("{}/{}.json", collection_dir, rkey);
+ let record_json = serde_json::json!({
+ "uri": record.uri,
+ "cid": record.cid,
+ "value": record.value,
+ });
+ fs::write(&record_path, serde_json::to_string_pretty(&record_json)?)?;
+ println!("Saved: {}", record_path);
+ }
+ println!("Synced {} records from {}", list.records.len(), collection);
+ }
+
+ println!("Sync complete!");
+
+ Ok(())
+}
diff --git a/src/token.rs b/src/token.rs
new file mode 100644
index 0000000..f37b5a6
--- /dev/null
+++ b/src/token.rs
@@ -0,0 +1,46 @@
+use anyhow::{Context, Result};
+use serde::{Deserialize, Serialize};
+use std::fs;
+use std::path::PathBuf;
+
+/// Bundle ID for the application
+pub const BUNDLE_ID: &str = "ai.syui.log";
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct Session {
+ pub did: String,
+ pub handle: String,
+ pub access_jwt: String,
+ pub refresh_jwt: String,
+ #[serde(default)]
+ pub pds: Option,
+}
+
+/// Get token file path: ~/Library/Application Support/ai.syui.log/token.json
+pub fn token_path() -> Result {
+ let config_dir = dirs::config_dir()
+ .context("Could not find config directory")?
+ .join(BUNDLE_ID);
+
+ fs::create_dir_all(&config_dir)?;
+ Ok(config_dir.join("token.json"))
+}
+
+/// Load session from token file
+pub fn load_session() -> Result {
+ let path = token_path()?;
+ let content = fs::read_to_string(&path)
+ .with_context(|| format!("Token file not found: {:?}. Run 'ailog login' first.", path))?;
+ let session: Session = serde_json::from_str(&content)?;
+ Ok(session)
+}
+
+/// Save session to token file
+pub fn save_session(session: &Session) -> Result<()> {
+ let path = token_path()?;
+ let content = serde_json::to_string_pretty(session)?;
+ fs::write(&path, content)?;
+ println!("Token saved to {:?}", path);
+ Ok(())
+}