From 03054b97cc7b5f84d0bfcb3abe03ec34afe94f04 Mon Sep 17 00:00:00 2001 From: Andy Waite <13400+andyw8@users.noreply.github.com> Date: Mon, 30 Jun 2025 19:28:30 -0400 Subject: [PATCH 1/6] Add CLAUDE.md --- CLAUDE.md | 56 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..b6db2eb --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,56 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Common Development Commands + +- **Setup**: `bin/setup` - Installs gem dependencies via bundle install +- **Tests**: `bundle exec rspec` - Runs the full test suite using RSpec +- **Console**: `bin/console` - Opens IRB with the gem loaded for interactive testing +- **Install locally**: `bundle exec rake install` - Installs gem to local machine +- **Build**: `bundle exec rake build` - Builds the gem file +- **Release**: `bundle exec rake release` - Creates git tag, pushes commits/tags, and publishes to RubyGems +- **CLI**: `readwise` - Command-line tool to send HTML content to Readwise Reader + +## Codebase Architecture + +This is a Ruby gem that provides a client library for the Readwise API. The architecture follows standard Ruby gem conventions: + +### Core Components + +- **Client** (`lib/readwise/client.rb`): Main API client handling both V2 (Highlights) and V3 (Reader) APIs + - V2 API (BASE_URL): Highlights, books, tags, daily review functionality + - V3 API (V3_BASE_URL): Reader documents functionality + - All HTTP requests use Net::HTTP with token-based authentication + - Pagination handled automatically for export and document listing + +- **Data Models**: Immutable structs representing API entities + - `Book`: Represents a book with highlights, metadata, and tags + - `Highlight`: Individual highlight with text, location, tags, and metadata + - `Document`: Reader documents (articles, PDFs) with reading progress and categorization + - `Tag`: Simple name/ID structure for organizing content + - `Review`: Daily review sessions with associated highlights + +### Key Patterns + +- **Transformation Methods**: Private methods in Client (`transform_*`) convert API responses to Ruby objects +- **Serialization**: Create/Update classes have `serialize` methods for API payloads +- **Pagination**: Automatic cursor-based pagination for large result sets +- **Error Handling**: Custom `Readwise::Client::Error` for API failures + +### Test Structure + +- Tests use RSpec with file fixtures from `spec/fixtures/` +- JSON fixtures represent actual API responses for consistent testing +- Test files mirror the lib directory structure +- Uses `rspec-file_fixtures` gem for loading test data +- CLI tests in `spec/readwise_spec.rb` test error handling and argument validation + +### CLI Tool + +The gem includes a `readwise` CLI command that reads HTML content from a file and sends it to Readwise Reader: + +- Requires `READWISE_API_KEY` environment variable +- Takes HTML file path as first argument +- Supports all DocumentCreate parameters as flags +- Example: `readwise --title="My Article" --location=later content.html` \ No newline at end of file From 50bd2220645335719105c9e99297d9723b7b77b9 Mon Sep 17 00:00:00 2001 From: Andy Waite <13400+andyw8@users.noreply.github.com> Date: Mon, 30 Jun 2025 19:37:59 -0400 Subject: [PATCH 2/6] Add CLI for creating documents --- README.md | 28 ++++- exe/readwise | 7 ++ lib/readwise/cli.rb | 64 ++++++++++ lib/readwise/cli/base_command.rb | 105 ++++++++++++++++ lib/readwise/cli/command_registry.rb | 24 ++++ lib/readwise/cli/document/create_command.rb | 127 ++++++++++++++++++++ spec/cli_spec.rb | 104 ++++++++++++++++ 7 files changed, 458 insertions(+), 1 deletion(-) create mode 100755 exe/readwise create mode 100644 lib/readwise/cli.rb create mode 100644 lib/readwise/cli/base_command.rb create mode 100644 lib/readwise/cli/command_registry.rb create mode 100644 lib/readwise/cli/document/create_command.rb create mode 100644 spec/cli_spec.rb diff --git a/README.md b/README.md index 300e2b0..67c5680 100644 --- a/README.md +++ b/README.md @@ -101,7 +101,7 @@ puts document.child? # is this a highlight/note of another doc # Check location puts document.in_new? -puts document.in_later? +puts document.in_later? puts document.in_archive? # Check category @@ -142,6 +142,32 @@ document = client.create_document(document: document_create) documents = client.create_documents(documents: [document_create1, document_create2]) ``` +## Command Line Interface + +This gem includes a `readwise` command-line tool for quickly sending HTML content to Readwise Reader. + +First, set your API token: +```bash +export READWISE_API_KEY=your_token_here +``` + +Then use the CLI to send HTML files: +```bash +# Basic usage +readwise document create --html-file content.html +readwise document create --url https://datatracker.ietf.org/doc/html/rfc2324 + +# Short form flag +readwise document create -f content.html + +# With options +readwise document create --html-file content.html --title="My Article" --location=later + +# See all available options +readwise --help +readwise document create --help +``` + ## Development After checking out the repo, run `bin/setup` to install dependencies. Then, run `bundle exec rspec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment. diff --git a/exe/readwise b/exe/readwise new file mode 100755 index 0000000..58f7d30 --- /dev/null +++ b/exe/readwise @@ -0,0 +1,7 @@ +#!/usr/bin/env ruby + +require 'bundler/setup' +require 'readwise' +require 'readwise/cli' + +Readwise::CLI.start diff --git a/lib/readwise/cli.rb b/lib/readwise/cli.rb new file mode 100644 index 0000000..7ac186a --- /dev/null +++ b/lib/readwise/cli.rb @@ -0,0 +1,64 @@ +require_relative 'cli/base_command' +require_relative 'cli/command_registry' + +module Readwise + class CLI + def self.start(args = ARGV) + new.start(args) + end + + def start(args) + if args.empty? + show_help + exit 1 + end + + if args.first == '--help' || args.first == '-h' + show_help + return + end + + resource = args.shift&.downcase + action = args.shift&.downcase + + unless resource && action + puts "Error: Resource and action are required" + puts "Usage: readwise [options] [arguments]" + puts "Run 'readwise --help' for more information" + exit 1 + end + + command_class = CommandRegistry.find(resource, action) + unless command_class + puts "Error: Unknown command '#{resource} #{action}'" + puts "Run 'readwise --help' to see available commands" + exit 1 + end + + command = command_class.new + command.execute(args) + rescue => e + puts "Unexpected error: #{e.message}" + exit 1 + end + + private + + def show_help + puts <<~HELP + Usage: readwise [options] [arguments] + + Available commands: + document create --html-file Send HTML content to Readwise Reader + + Global options: + -h, --help Show this help message + + Examples: + readwise document create --html-file content.html --title="My Article" + readwise document create -f content.html --title="My Article" + readwise document create --help + HELP + end + end +end diff --git a/lib/readwise/cli/base_command.rb b/lib/readwise/cli/base_command.rb new file mode 100644 index 0000000..73614ac --- /dev/null +++ b/lib/readwise/cli/base_command.rb @@ -0,0 +1,105 @@ +require 'optparse' + +module Readwise + class CLI + class BaseCommand + def initialize + @options = {} + end + + def execute(args) + parse_options(args) + validate_arguments(args) + run(args) + rescue OptionParser::InvalidOption => e + puts "Error: #{e.message}" + show_help + exit 1 + rescue ArgumentError => e + puts "Error: #{e.message}" + exit 1 + end + + private + + attr_reader :options + + def parse_options(args) + parser = create_option_parser + parser.parse!(args) + end + + def create_option_parser + OptionParser.new do |opts| + opts.banner = banner + opts.separator "" + opts.separator description if description + opts.separator "" + opts.separator "Options:" + + add_options(opts) + + opts.on("-h", "--help", "Show this help message") do + show_help + exit + end + end + end + + def show_help + puts create_option_parser.help + end + + def get_api_client + token = ENV['READWISE_API_KEY'] + unless token + puts "Error: READWISE_API_KEY environment variable is not set" + exit 1 + end + + Readwise::Client.new(token: token) + end + + def read_file(file_path) + unless File.exist?(file_path) + puts "Error: File '#{file_path}' not found" + exit 1 + end + + File.read(file_path) + end + + def handle_api_error(&block) + yield + rescue Readwise::Client::Error => e + puts "API Error: #{e.message}" + exit 1 + rescue => e + puts "Unexpected error: #{e.message}" + exit 1 + end + + # Override these methods in subclasses + + def banner + "Usage: readwise" + end + + def description + nil + end + + def add_options(opts) + # Override in subclasses to add specific options + end + + def validate_arguments(args) + # Override in subclasses to validate arguments + end + + def run(args) + raise NotImplementedError, "Subclasses must implement #run" + end + end + end +end diff --git a/lib/readwise/cli/command_registry.rb b/lib/readwise/cli/command_registry.rb new file mode 100644 index 0000000..1137059 --- /dev/null +++ b/lib/readwise/cli/command_registry.rb @@ -0,0 +1,24 @@ +module Readwise + class CLI + class CommandRegistry + @commands = {} + + def self.register(resource, action, command_class) + key = "#{resource}:#{action}" + @commands[key] = command_class + end + + def self.find(resource, action) + key = "#{resource}:#{action}" + @commands[key] + end + + def self.all_commands + @commands.keys.map { |key| key.split(':') } + end + end + end +end + +# Register available commands +require_relative 'document/create_command' diff --git a/lib/readwise/cli/document/create_command.rb b/lib/readwise/cli/document/create_command.rb new file mode 100644 index 0000000..f5a737b --- /dev/null +++ b/lib/readwise/cli/document/create_command.rb @@ -0,0 +1,127 @@ +require_relative '../base_command' + +module Readwise + class CLI + module Document + class CreateCommand < BaseCommand + def banner + "Usage: readwise document create [options]" + end + + def description + "Sends HTML content to Readwise Reader API" + end + + def add_options(opts) + opts.on("-f", "--html-file=FILE", "HTML file path") do |file| + options[:file] = file + end + + opts.on("--title=TITLE", "Document title") do |title| + options[:title] = title + end + + opts.on("--author=AUTHOR", "Document author") do |author| + options[:author] = author + end + + opts.on("-u", "--url=URL", "Source URL (defaults to https://example.com/)") do |url| + options[:url] = url + end + + opts.on("--summary=SUMMARY", "Document summary") do |summary| + options[:summary] = summary + end + + opts.on("--notes=NOTES", "Personal notes") do |notes| + options[:notes] = notes + end + + opts.on("--location=LOCATION", "Document location: new, later, archive, feed (default: new)") do |location| + unless %w[new later archive feed].include?(location) + puts "Error: Invalid location. Must be one of: new, later, archive, feed" + exit 1 + end + options[:location] = location + end + + opts.on("--category=CATEGORY", "Document category: article, email, rss, highlight, note, pdf, epub, tweet, video") do |category| + unless %w[article email rss highlight note pdf epub tweet video].include?(category) + puts "Error: Invalid category. Must be one of: article, email, rss, highlight, note, pdf, epub, tweet, video" + exit 1 + end + options[:category] = category + end + + opts.on("--tags=TAGS", "Comma-separated list of tags") do |tags| + options[:tags] = tags.split(',').map(&:strip) + end + + opts.on("--image-url=URL", "Image URL") do |image_url| + options[:image_url] = image_url + end + + opts.on("--published-date=DATE", "Published date (ISO 8601 format)") do |date| + options[:published_date] = date + end + + opts.on("--[no-]clean-html", "Clean HTML (default: true)") do |clean| + options[:should_clean_html] = clean + end + + opts.on("--saved-using=SOURCE", "Saved using source (default: cli)") do |source| + options[:saved_using] = source + end + end + + def validate_arguments(args) + unless options[:file] || options[:url] + puts "Error: File path or URL is required" + show_help + exit 1 + end + end + + def run(args) + html_file = options[:file] + html_content = read_file(html_file) if html_file + + document_params = build_document_params(html_content, html_file) + + handle_api_error do + client = get_api_client + document_create = Readwise::DocumentCreate.new(**document_params) + document = client.create_document(document: document_create) + + puts "Document created successfully!" + puts "ID: #{document.id}" + puts "Title: #{document.title}" + puts "Location: #{document.location}" + puts "URL: #{document.url}" if document.url + end + end + + private + + def build_document_params(html_content, html_file) + document_params = { + html: html_content, + location: options[:location] || 'new', + should_clean_html: options.key?(:should_clean_html) ? options[:should_clean_html] : true, + saved_using: options[:saved_using] || 'cli', + url: options[:url] || "https://example.com/#{File.basename(html_file)}" + } + + [:title, :author, :summary, :notes, :category, :tags, :image_url, :published_date].each do |key| + document_params[key] = options[key] if options[key] + end + + document_params + end + end + end + end +end + +# Register this command +Readwise::CLI::CommandRegistry.register('document', 'create', Readwise::CLI::Document::CreateCommand) diff --git a/spec/cli_spec.rb b/spec/cli_spec.rb new file mode 100644 index 0000000..ef2eb8a --- /dev/null +++ b/spec/cli_spec.rb @@ -0,0 +1,104 @@ +require 'spec_helper' +require 'tempfile' +require 'open3' + +RSpec.describe 'readwise CLI' do + let(:cli_path) { File.expand_path('../../exe/readwise', __FILE__) } + let(:test_html) { '

Test content

' } + let(:temp_file) do + file = Tempfile.new(['test', '.html']) + file.write(test_html) + file.close + file + end + + after do + temp_file.unlink if temp_file + end + + def run_cli(*args) + env = { 'READWISE_API_KEY' => 'test_token' } + Open3.capture3(env, 'ruby', cli_path, *args) + end + + def run_cli_without_token(*args) + env = {} + Open3.capture3(env, 'ruby', cli_path, *args) + end + + describe 'help and error handling' do + it 'shows help when --help is passed' do + stdout, _stderr, status = run_cli('--help') + + expect(status.success?).to be true + expect(stdout).to include('Usage: readwise') + expect(stdout).to include('Available commands:') + end + + it 'shows help when no arguments are provided' do + stdout, _stderr, status = run_cli() + + expect(status.success?).to be false + expect(stdout).to include('Usage: readwise ') + end + + it 'shows error for unknown command' do + stdout, _stderr, status = run_cli('unknown', 'command') + + expect(status.success?).to be false + expect(stdout).to include("Error: Unknown command 'unknown command'") + end + + it 'shows error when resource or action is missing' do + stdout, _stderr, status = run_cli('document') + + expect(status.success?).to be false + expect(stdout).to include('Error: Resource and action are required') + end + end + + describe 'document create command' do + it 'shows help for document create command' do + stdout, _stderr, status = run_cli('document', 'create', '--help') + + expect(status.success?).to be true + expect(stdout).to include('Usage: readwise document create') + expect(stdout).to include('Sends HTML content to Readwise Reader API') + end + + it 'shows error when neither a file path or URL is provided' do + stdout, _stderr, status = run_cli('document', 'create') + + expect(status.success?).to be false + expect(stdout).to include('Error: File path or URL is required') + end + + it 'shows error when file does not exist' do + stdout, _stderr, status = run_cli('document', 'create', '--html-file', 'nonexistent.html') + + expect(status.success?).to be false + expect(stdout).to include("Error: File 'nonexistent.html' not found") + end + + it 'shows error when READWISE_API_KEY is not set' do + stdout, _stderr, status = run_cli_without_token('document', 'create', '--html-file', temp_file.path) + + expect(status.success?).to be false + expect(stdout).to include('Error: READWISE_API_KEY environment variable is not set') + end + + it 'shows error for invalid location' do + stdout, _stderr, status = run_cli('document', 'create', '--location=invalid', '--html-file', temp_file.path) + + expect(status.success?).to be false + expect(stdout).to include('Error: Invalid location. Must be one of: new, later, archive, feed') + end + + it 'shows error for invalid category' do + stdout, _stderr, status = run_cli('document', 'create', '--category=invalid', '--html-file', temp_file.path) + + expect(status.success?).to be false + expect(stdout).to include('Error: Invalid category. Must be one of: article, email, rss, highlight, note, pdf, epub, tweet, video') + end + end +end From 8723655c871905f9db08ff4f4c923a8c11ccdb9c Mon Sep 17 00:00:00 2001 From: Andy Waite Date: Tue, 1 Jul 2025 10:09:51 -0400 Subject: [PATCH 3/6] Update CLAUDE.md Co-authored-by: Josh Beckman --- CLAUDE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index b6db2eb..6931e36 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -48,7 +48,7 @@ This is a Ruby gem that provides a client library for the Readwise API. The arch ### CLI Tool -The gem includes a `readwise` CLI command that reads HTML content from a file and sends it to Readwise Reader: +The gem includes a `readwise` CLI with `document create` command that reads HTML content from a file and sends it to Readwise Reader: - Requires `READWISE_API_KEY` environment variable - Takes HTML file path as first argument From b7aab9c8fc613c7aa22186d2e77fdf38a866373c Mon Sep 17 00:00:00 2001 From: Andy Waite Date: Tue, 1 Jul 2025 10:10:06 -0400 Subject: [PATCH 4/6] Update CLAUDE.md Co-authored-by: Josh Beckman --- CLAUDE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index 6931e36..5f0699a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -53,4 +53,4 @@ The gem includes a `readwise` CLI with `document create` command that reads HTM - Requires `READWISE_API_KEY` environment variable - Takes HTML file path as first argument - Supports all DocumentCreate parameters as flags -- Example: `readwise --title="My Article" --location=later content.html` \ No newline at end of file +- Example: `readwise document create --title="My Article" --html-file=content.html` \ No newline at end of file From c89cc0fbb5cb1ea8ac985566e859e36e5dc55135 Mon Sep 17 00:00:00 2001 From: Andy Waite <13400+andyw8@users.noreply.github.com> Date: Tue, 1 Jul 2025 10:16:09 -0400 Subject: [PATCH 5/6] Extract document categories into constant --- lib/readwise/cli/document/create_command.rb | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/lib/readwise/cli/document/create_command.rb b/lib/readwise/cli/document/create_command.rb index f5a737b..a68a38f 100644 --- a/lib/readwise/cli/document/create_command.rb +++ b/lib/readwise/cli/document/create_command.rb @@ -4,6 +4,7 @@ module Readwise class CLI module Document class CreateCommand < BaseCommand + CATEGORIES = %w[article email rss highlight note pdf epub tweet video].freeze def banner "Usage: readwise document create [options]" end @@ -45,9 +46,9 @@ def add_options(opts) options[:location] = location end - opts.on("--category=CATEGORY", "Document category: article, email, rss, highlight, note, pdf, epub, tweet, video") do |category| - unless %w[article email rss highlight note pdf epub tweet video].include?(category) - puts "Error: Invalid category. Must be one of: article, email, rss, highlight, note, pdf, epub, tweet, video" + opts.on("--category=CATEGORY", "Document category: #{CATEGORIES.join(', ')}") do |category| + unless CATEGORIES.include?(category) + puts "Error: Invalid category. Must be one of: #{CATEGORIES.join(', ')}" exit 1 end options[:category] = category From 8a655eb9f254fbb5780368e6d0677770e767e268 Mon Sep 17 00:00:00 2001 From: Andy Waite <13400+andyw8@users.noreply.github.com> Date: Tue, 1 Jul 2025 10:19:00 -0400 Subject: [PATCH 6/6] Remove CLAUDE.md --- CLAUDE.md | 56 ------------------------------------------------------- 1 file changed, 56 deletions(-) delete mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index 5f0699a..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1,56 +0,0 @@ -# CLAUDE.md - -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. - -## Common Development Commands - -- **Setup**: `bin/setup` - Installs gem dependencies via bundle install -- **Tests**: `bundle exec rspec` - Runs the full test suite using RSpec -- **Console**: `bin/console` - Opens IRB with the gem loaded for interactive testing -- **Install locally**: `bundle exec rake install` - Installs gem to local machine -- **Build**: `bundle exec rake build` - Builds the gem file -- **Release**: `bundle exec rake release` - Creates git tag, pushes commits/tags, and publishes to RubyGems -- **CLI**: `readwise` - Command-line tool to send HTML content to Readwise Reader - -## Codebase Architecture - -This is a Ruby gem that provides a client library for the Readwise API. The architecture follows standard Ruby gem conventions: - -### Core Components - -- **Client** (`lib/readwise/client.rb`): Main API client handling both V2 (Highlights) and V3 (Reader) APIs - - V2 API (BASE_URL): Highlights, books, tags, daily review functionality - - V3 API (V3_BASE_URL): Reader documents functionality - - All HTTP requests use Net::HTTP with token-based authentication - - Pagination handled automatically for export and document listing - -- **Data Models**: Immutable structs representing API entities - - `Book`: Represents a book with highlights, metadata, and tags - - `Highlight`: Individual highlight with text, location, tags, and metadata - - `Document`: Reader documents (articles, PDFs) with reading progress and categorization - - `Tag`: Simple name/ID structure for organizing content - - `Review`: Daily review sessions with associated highlights - -### Key Patterns - -- **Transformation Methods**: Private methods in Client (`transform_*`) convert API responses to Ruby objects -- **Serialization**: Create/Update classes have `serialize` methods for API payloads -- **Pagination**: Automatic cursor-based pagination for large result sets -- **Error Handling**: Custom `Readwise::Client::Error` for API failures - -### Test Structure - -- Tests use RSpec with file fixtures from `spec/fixtures/` -- JSON fixtures represent actual API responses for consistent testing -- Test files mirror the lib directory structure -- Uses `rspec-file_fixtures` gem for loading test data -- CLI tests in `spec/readwise_spec.rb` test error handling and argument validation - -### CLI Tool - -The gem includes a `readwise` CLI with `document create` command that reads HTML content from a file and sends it to Readwise Reader: - -- Requires `READWISE_API_KEY` environment variable -- Takes HTML file path as first argument -- Supports all DocumentCreate parameters as flags -- Example: `readwise document create --title="My Article" --html-file=content.html` \ No newline at end of file