Task changes
donescreenote · add-a-go-cli-for-260708-edec
Available
Diff available. Open the public pull request for the complete change.
Committed changes
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 95c0173..628a414 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -13,9 +13,6 @@ jobs:
- name: Checkout code
uses: actions/checkout@v6
- - name: Install system packages
- run: sudo apt-get update && sudo apt-get install --no-install-recommends -y libvips
-
- name: Set up Ruby
uses: ruby/setup-ruby@v1
with:
@@ -34,9 +31,6 @@ jobs:
- name: Checkout code
uses: actions/checkout@v6
- - name: Install system packages
- run: sudo apt-get update && sudo apt-get install --no-install-recommends -y libvips
-
- name: Set up Ruby
uses: ruby/setup-ruby@v1
with:
@@ -84,6 +78,9 @@ jobs:
- name: Checkout code
uses: actions/checkout@v6
+ - name: Install system packages
+ run: sudo apt-get update && sudo apt-get install --no-install-recommends -y libvips
+
- name: Set up Ruby
uses: ruby/setup-ruby@v1
with:
@@ -96,8 +93,34 @@ jobs:
# REDIS_URL: redis://localhost:6379/0
run: bin/rails db:test:prepare test
+ test_go:
+ runs-on: ubuntu-latest
+ env:
+ GOFLAGS: -mod=mod
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v6
+
+ - name: Set up Go
+ uses: actions/setup-go@v6
+ with:
+ go-version: '1.26.x'
+ cache: true
+
+ - name: Run Go tests
+ run: go test ./...
+
+ - name: Cross-compile CLI
+ run: |
+ for os in linux darwin; do
+ for arch in amd64 arm64; do
+ GOOS="$os" GOARCH="$arch" go build -o "tmp/screenote-${os}-${arch}" ./cmd/screenote
+ done
+ done
+
system-test:
runs-on: ubuntu-latest
+ timeout-minutes: 15
# services:
# redis:
@@ -109,14 +132,26 @@ jobs:
- name: Checkout code
uses: actions/checkout@v6
+ - name: Install system packages
+ run: sudo apt-get update && sudo apt-get install --no-install-recommends -y libvips
+
- name: Set up Ruby
uses: ruby/setup-ruby@v1
with:
bundler-cache: true
+ - name: Install Playwright browsers
+ run: |
+ export PLAYWRIGHT_CLI_VERSION="$(ruby -e 'require "playwright"; puts Playwright::COMPATIBLE_PLAYWRIGHT_VERSION.strip')"
+ npm install --no-save "playwright@$PLAYWRIGHT_CLI_VERSION"
+ npx playwright install --with-deps chromium
+
- name: Run System Tests
env:
RAILS_ENV: test
+ CAPYBARA_RUN_SERVER: "true"
+ PARALLEL_WORKERS: "1"
+ TESTOPTS: "--verbose"
# RAILS_MASTER_KEY: ${{ secrets.RAILS_MASTER_KEY }}
# REDIS_URL: redis://localhost:6379/0
run: bin/rails db:test:prepare test:system
diff --git a/.gitignore b/.gitignore
index 5fad0aa..8b22392 100644
--- a/.gitignore
+++ b/.gitignore
@@ -29,6 +29,7 @@
!/tmp/storage/.keep
/public/assets
+/coverage
/vendor/bundle
# Ignore key files for decrypting credentials and more.
diff --git a/.goreleaser.yml b/.goreleaser.yml
new file mode 100644
index 0000000..1a129c6
--- /dev/null
+++ b/.goreleaser.yml
@@ -0,0 +1,44 @@
+version: 2
+
+project_name: screenote
+
+env:
+ - GOFLAGS=-mod=mod
+
+before:
+ hooks:
+ - go mod tidy
+
+builds:
+ - id: screenote
+ main: ./cmd/screenote
+ binary: screenote
+ goos:
+ - linux
+ - darwin
+ goarch:
+ - amd64
+ - arm64
+ ldflags:
+ - -s -w
+
+archives:
+ - id: screenote
+ ids:
+ - screenote
+ formats:
+ - tar.gz
+ name_template: >-
+ {{ .ProjectName }}_
+ {{- .Version }}_
+ {{- .Os }}_
+ {{- .Arch }}
+
+checksum:
+ name_template: checksums.txt
+
+snapshot:
+ version_template: "{{ incpatch .Version }}-next"
+
+changelog:
+ use: github
diff --git a/Gemfile b/Gemfile
index 54891f1..e01b4c4 100644
--- a/Gemfile
+++ b/Gemfile
@@ -94,4 +94,5 @@ group :test do
# Use system testing [https://guides.rubyonrails.org/testing.html#system-testing]
gem "capybara"
gem "capybara-playwright-driver"
+ gem "simplecov", require: false
end
diff --git a/Gemfile.lock b/Gemfile.lock
index 959ae5f..a765f1e 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -123,7 +123,7 @@ GEM
rack-test (>= 0.6.3)
regexp_parser (>= 1.5, < 3.0)
xpath (~> 3.2)
- capybara-playwright-driver (0.5.7)
+ capybara-playwright-driver (0.5.10)
addressable
capybara
playwright-ruby-client (>= 1.16.0)
@@ -137,6 +137,7 @@ GEM
debug (1.11.1)
irb (~> 1.10)
reline (>= 0.3.8)
+ docile (1.4.1)
doorkeeper (5.8.2)
railties (>= 5)
dotenv (3.2.0)
@@ -264,7 +265,7 @@ GEM
mime-types (3.7.0)
logger
mime-types-data (~> 3.2025, >= 3.2025.0507)
- mime-types-data (3.2026.0203)
+ mime-types-data (3.2026.0701)
mini_magick (5.3.1)
logger
mini_mime (1.1.5)
@@ -339,7 +340,7 @@ GEM
pg (1.6.3-aarch64-linux-musl)
pg (1.6.3-x86_64-linux)
pg (1.6.3-x86_64-linux-musl)
- playwright-ruby-client (1.58.0)
+ playwright-ruby-client (1.61.0)
base64
concurrent-ruby (>= 1.1.6)
mime-types (>= 3.0)
@@ -412,7 +413,7 @@ GEM
prism (>= 1.6.0)
rbs (>= 4.0.0)
tsort
- regexp_parser (2.11.3)
+ regexp_parser (2.12.0)
reline (0.6.3)
io-console (~> 0.5)
resend (1.0.0)
@@ -450,6 +451,12 @@ GEM
ffi (~> 1.12)
logger
securerandom (0.4.1)
+ simplecov (0.22.0)
+ docile (~> 1.1)
+ simplecov-html (~> 0.11)
+ simplecov_json_formatter (~> 0.1)
+ simplecov-html (0.13.2)
+ simplecov_json_formatter (0.1.4)
snaky_hash (2.0.6)
hashie (>= 0.1.0, < 6)
version_gem (>= 1.1.8, < 3)
@@ -552,6 +559,7 @@ DEPENDENCIES
rails_simple_auth (~> 1.1.0)
resend
rubocop-rails-omakase
+ simplecov
solid_cable
solid_cache
solid_queue
diff --git a/README.md b/README.md
index 7db80e4..3a3b936 100644
--- a/README.md
+++ b/README.md
@@ -1,24 +1,70 @@
-# README
+# Screenote
-This README would normally document whatever steps are necessary to get the
-application up and running.
+Screenote is a Rails visual feedback app for screenshots, annotations, and agent workflows. The browser app is the review surface, MCP is the rich agent protocol, and the Go CLI is the shell/CI REST contract.
-Things you may want to cover:
+## Go CLI
-* Ruby version
+Install:
-* System dependencies
+```sh
+go install github.com/ivankuznetsov/screenote/cmd/screenote@latest
+```
-* Configuration
+Configure a self-hosted, local, staging, or production base URL explicitly:
-* Database creation
+```sh
+screenote config set --base-url https://screenote.ai --api-key sk_proj_...
+screenote config
+```
-* Database initialization
+Configuration precedence is:
-* How to run the test suite
+1. Flags: `--api-key`, `--base-url`, `--project`
+2. Environment: `SCREENOTE_API_KEY`, `SCREENOTE_BASE_URL`, `SCREENOTE_PROJECT`
+3. Config file: `~/.config/screenote/config.toml`
-* Services (job queues, cache servers, search engines, etc.)
+Examples:
-* Deployment instructions
+```sh
+screenote --base-url http://localhost:3005 --api-key "$SCREENOTE_API_KEY" project list
+screenote page list
+screenote screenshot create --title "Homepage" --file screenshot.png
+cat screenshot.png | screenote screenshot create --title "Homepage"
+screenote screenshot list --status ready --limit 25
+screenote annotation list --screenshot 123 --status open
+screenote annotation get --annotation 456
+screenote comment add --annotation 456 --body "Fix pushed in abc123"
+```
-* ...
+Successful commands write JSON to stdout. Errors write JSON to stderr:
+
+```json
+{"code":"missing_base_url","error":"base URL is required; set --base-url, SCREENOTE_BASE_URL, or config base_url"}
+```
+
+Exit codes:
+
+| Code | Meaning |
+| --- | --- |
+| 0 | OK |
+| 1 | Generic error |
+| 2 | Usage/config error |
+| 3 | Auth/forbidden |
+| 4 | Not found |
+| 5 | Rate limited |
+
+## Development
+
+Rails tests:
+
+```sh
+bin/rails test
+```
+
+Go tests:
+
+```sh
+GOFLAGS=-mod=mod go test ./...
+```
+
+`GOFLAGS=-mod=mod` is required because this Rails repo has a top-level `vendor/` directory that is not a Go vendor tree.
diff --git a/app/controllers/api/base_controller.rb b/app/controllers/api/base_controller.rb
index a8907b5..5bdd46d 100644
--- a/app/controllers/api/base_controller.rb
+++ b/app/controllers/api/base_controller.rb
@@ -5,11 +5,16 @@ class BaseController < ActionController::API
before_action :authenticate_api_key!
rescue_from ActiveRecord::RecordInvalid do |e|
- render json: { error: e.record.errors.full_messages.join(", ") }, status: :unprocessable_entity
+ render_error(
+ e.record.errors.full_messages.join(", "),
+ code: "validation_failed",
+ status: :unprocessable_entity,
+ details: e.record.errors.full_messages
+ )
end
rescue_from ActiveRecord::RecordNotFound do |e|
- render json: { error: e.message }, status: :not_found
+ render_error(e.message, code: "not_found", status: :not_found)
end
private
@@ -19,7 +24,7 @@ def authenticate_api_key!
api_key = ApiKey.active.find_by_token(token)
if api_key.nil?
- render json: { error: "Invalid or missing API key" }, status: :unauthorized
+ render_error("Invalid or missing API key", code: "unauthorized", status: :unauthorized)
return
end
@@ -33,6 +38,36 @@ def extract_bearer_token
header&.match(/\ABearer\s+(.+)\z/)&.captures&.first
end
+ def require_current_project!(project_id)
+ return current_project if project_id.blank?
+ return current_project if project_id.to_s == current_project.id.to_s
+
+ render_error("Project is not accessible with this API key", code: "forbidden", status: :forbidden)
+ nil
+ end
+
+ def pagination_params
+ limit = integer_param(:limit, 50).clamp(1, 100)
+ offset = [ integer_param(:offset, 0), 0 ].max
+
+ [ limit, offset ]
+ end
+
+ # Coerce only scalar pagination values; structured params such as
+ # `limit[x]=1` or `offset[]=2` are treated as absent rather than raising.
+ def integer_param(key, default)
+ value = params[key]
+ return default unless value.is_a?(String) || value.is_a?(Integer)
+
+ value.to_i
+ end
+
+ def render_error(message, code:, status:, details: nil)
+ payload = { error: message, code: code }
+ payload[:details] = details if details
+ render json: payload, status: status
+ end
+
attr_reader :current_api_key, :current_project
end
end
diff --git a/app/controllers/api/v1/annotation_comments_controller.rb b/app/controllers/api/v1/annotation_comments_controller.rb
new file mode 100644
index 0000000..86cbffb
--- /dev/null
+++ b/app/controllers/api/v1/annotation_comments_controller.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+module Api
+ module V1
+ class AnnotationCommentsController < Api::BaseController
+ def create
+ annotation = Api::V1::ProjectScope.annotations(current_project).find(params[:annotation_id])
+ comment = annotation.annotation_comments.create!(
+ api_key: current_api_key,
+ body: params[:body],
+ action: :comment
+ )
+
+ render json: {
+ success: true,
+ comment: Api::V1::ContractSerializer.annotation_comment(comment, annotation: annotation)
+ }, status: :created
+ end
+ end
+ end
+end
diff --git a/app/controllers/api/v1/annotations_controller.rb b/app/controllers/api/v1/annotations_controller.rb
index fbdd837..b020a3a 100644
--- a/app/controllers/api/v1/annotations_controller.rb
+++ b/app/controllers/api/v1/annotations_controller.rb
@@ -4,25 +4,44 @@ module Api
module V1
class AnnotationsController < Api::BaseController
def index
+ limit, offset = pagination_params
annotations = project_annotations.order(:created_at)
annotations = annotations.where(screenshot_id: params[:screenshot_id]) if params[:screenshot_id].present?
annotations = annotations.where(status: params[:status]) if params[:status].present?
+ annotations = annotations.where(viewport: params[:viewport]) if params[:viewport].present?
+ total = annotations.count
+ annotations = annotations.limit(limit).offset(offset)
render json: {
- annotations: annotations.map { |a| serialize(a) }
+ annotations: annotations.map { |a| serialize(a) },
+ pagination: { total: total, limit: limit, offset: offset }
}
end
+ def show
+ annotation = project_annotations.find(params[:id])
+ cropped_base64 = begin
+ annotation.crop
+ rescue => e
+ Honeybadger.notify(e, context: {
+ annotation_id: annotation.id,
+ screenshot_id: annotation.screenshot_id,
+ viewport: annotation.viewport
+ })
+ nil
+ end
+
+ render json: Api::V1::ContractSerializer.annotation_detail(annotation, cropped_base64: cropped_base64)
+ end
+
private
def project_annotations
- Annotation.joins(screenshot: :project)
- .where(projects: { id: current_project.id })
- .includes(:user, :screenshot, :annotation_comments)
+ Api::V1::ProjectScope.annotations(current_project)
end
def serialize(annotation)
- annotation.as_api_json
+ Api::V1::ContractSerializer.annotation(annotation)
end
end
end
diff --git a/app/controllers/api/v1/pages_controller.rb b/app/controllers/api/v1/pages_controller.rb
new file mode 100644
index 0000000..8d7e6f0
--- /dev/null
+++ b/app/controllers/api/v1/pages_controller.rb
@@ -0,0 +1,24 @@
+# frozen_string_literal: true
+
+module Api
+ module V1
+ class PagesController < Api::BaseController
+ def index
+ project = require_current_project!(params[:project_id])
+ return unless project
+
+ render json: {
+ pages: Api::V1::ProjectScope.pages(project).map do |page|
+ Api::V1::ContractSerializer.page(page, url_options: url_options)
+ end
+ }
+ end
+
+ private
+
+ def url_options
+ { host: request.host, port: request.optional_port, protocol: request.protocol }
+ end
+ end
+ end
+end
diff --git a/app/controllers/api/v1/projects_controller.rb b/app/controllers/api/v1/projects_controller.rb
new file mode 100644
index 0000000..64c2222
--- /dev/null
+++ b/app/controllers/api/v1/projects_controller.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+module Api
+ module V1
+ class ProjectsController < Api::BaseController
+ def index
+ render json: {
+ projects: [
+ Api::V1::ContractSerializer.project(current_project).merge(role: "api_key")
+ ]
+ }
+ end
+ end
+ end
+end
diff --git a/app/controllers/api/v1/screenshots_controller.rb b/app/controllers/api/v1/screenshots_controller.rb
index 53465a0..15c92a5 100644
--- a/app/controllers/api/v1/screenshots_controller.rb
+++ b/app/controllers/api/v1/screenshots_controller.rb
@@ -3,36 +3,61 @@
module Api
module V1
class ScreenshotsController < Api::BaseController
+ def index
+ project = require_current_project!(params[:project_id])
+ return unless project
+
+ limit, offset = pagination_params
+ screenshots = Api::V1::ProjectScope.screenshots(project)
+ screenshots = screenshots.where(page_id: params[:page_id]) if params[:page_id].present?
+ screenshots = screenshots.where(status: params[:status]) if params[:status].present?
+
+ total = screenshots.count
+ screenshots = screenshots.limit(limit).offset(offset)
+
+ render json: {
+ screenshots: screenshots.map { |screenshot| Api::V1::ContractSerializer.screenshot(screenshot, url_options: url_options) },
+ pagination: { total: total, limit: limit, offset: offset }
+ }
+ end
+
def create
- unless params[:image]
- render json: { error: "Image file is required" }, status: :unprocessable_entity
+ image = params[:image]
+ unless uploaded_file?(image)
+ render_error("Image file is required", code: "validation_failed", status: :unprocessable_entity)
return
end
title = params[:title].presence || "Untitled"
- page = Page.find_or_create_by_name!(current_project, title)
+ page = resolve_page(title)
screenshot = Screenshot.create_with_image!(
page: page, title: title,
- io: params[:image].tempfile,
- filename: params[:image].original_filename,
- content_type: params[:image].content_type
+ io: image.tempfile,
+ filename: image.original_filename,
+ content_type: image.content_type
)
- render json: {
- screenshot_id: screenshot.id,
- annotate_url: screenshot_url(screenshot)
- }, status: :created
- rescue ActiveRecord::RecordInvalid => e
- render json: { error: e.record.errors.full_messages.join(", ") }, status: :unprocessable_entity
+ render json: Api::V1::ContractSerializer.screenshot_create(screenshot, url_options: url_options), status: :created
end
private
- def screenshot_url(screenshot)
- Rails.application.routes.url_helpers.screenshot_url(
- screenshot,
- host: request.host, port: request.port, protocol: request.protocol
- )
+ def uploaded_file?(image)
+ image.respond_to?(:tempfile) &&
+ image.respond_to?(:original_filename) &&
+ image.respond_to?(:content_type)
+ end
+
+ def resolve_page(title)
+ page_id = params[:page_id].presence || params[:page].presence
+ return current_project.pages.find(page_id) if page_id.to_s.match?(/\A\d+\z/)
+ return Page.find_or_create_by_name!(current_project, page_id) if page_id.present?
+
+ Page.find_or_create_by_name!(current_project, title)
+ end
+
+ def url_options
+ { host: request.host, port: request.optional_port, protocol: request.protocol }
end
end
end
diff --git a/app/serializers/api/v1/contract_serializer.rb b/app/serializers/api/v1/contract_serializer.rb
new file mode 100644
index 0000000..6f3938e
--- /dev/null
+++ b/app/serializers/api/v1/contract_serializer.rb
@@ -0,0 +1,101 @@
+# frozen_string_literal: true
+
+module Api
+ module V1
+ class ContractSerializer
+ class << self
+ def project(project)
+ {
+ id: project.id,
+ name: project.name,
+ screenshot_count: project.screenshots.count,
+ created_at: project.created_at.iso8601
+ }
+ end
+
+ def page(page, url_options: {})
+ {
+ id: page.id,
+ name: page.name,
+ version_count: page.version_count.to_i,
+ url: routes.page_url(page, url_options),
+ created_at: page.created_at.iso8601
+ }
+ end
+
+ def screenshot(screenshot, url_options: {})
+ {
+ id: screenshot.id,
+ title: screenshot.title,
+ page_id: screenshot.page_id,
+ page_name: screenshot.page.name,
+ status: screenshot.status,
+ annotation_count: screenshot.annotations.size,
+ unresolved_count: screenshot.annotations.count(&:open?),
+ annotate_url: routes.screenshot_url(screenshot, url_options),
+ viewports: screenshot.screenshot_images.sort_by(&:viewport).map { |image| screenshot_image(image) },
+ created_at: screenshot.created_at.iso8601
+ }
+ end
+
+ def screenshot_create(screenshot, url_options: {})
+ {
+ screenshot_id: screenshot.id,
+ page_id: screenshot.page_id,
+ status: screenshot.status,
+ annotate_url: routes.screenshot_url(screenshot, url_options),
+ image: screenshot_image(screenshot.primary_image)
+ }
+ end
+
+ def annotation(annotation)
+ annotation.as_api_json
+ end
+
+ def annotation_detail(annotation, cropped_base64:)
+ comments = annotation.annotation_comments.sort_by(&:created_at).map do |comment|
+ annotation_comment(comment, annotation: annotation, include_annotation_id: false)
+ end
+
+ annotation(annotation).merge(
+ screenshot_status: annotation.screenshot.status,
+ cropped_image_base64: cropped_base64,
+ mime_type: "image/png",
+ comments: comments
+ )
+ end
+
+ def annotation_comment(comment, annotation: comment.annotation, include_annotation_id: true)
+ payload = {
+ id: comment.id,
+ action: comment.action,
+ body: comment.body,
+ author: comment.user&.email || comment.api_key&.name || "Unknown",
+ created_at: comment.created_at.iso8601
+ }
+ payload[:annotation_id] = annotation.id if include_annotation_id
+ payload
+ end
+
+ private
+
+ def screenshot_image(image)
+ return nil unless image
+
+ {
+ id: image.id,
+ viewport: image.viewport,
+ status: image.status,
+ width: image.width,
+ height: image.height,
+ attached: image.image.attached?
+ }
+ end
+
+ def routes
+ Rails.application.routes.url_helpers
+ end
+ end
+ end
+ end
+end
diff --git a/app/services/api/v1/project_scope.rb b/app/services/api/v1/project_scope.rb
new file mode 100644
index 0000000..0cf6ef0
--- /dev/null
+++ b/app/services/api/v1/project_scope.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+module Api
+ module V1
+ class ProjectScope
+ def self.pages(project)
+ project.pages
+ .left_joins(:screenshots)
+ .select("pages.*, COUNT(screenshots.id) AS version_count")
+ .group("pages.id")
+ .order(:created_at)
+ end
+
+ def self.screenshots(project)
+ project.screenshots.includes(:page, :annotations, :screenshot_images).order(created_at: :desc)
+ end
+
+ def self.annotations(project)
+ Annotation.joins(screenshot: { page: :project })
+ .where(projects: { id: project.id })
+ .includes(:user, :screenshot, annotation_comments: [ :user, :api_key ])
+ end
+ end
+ end
+end
diff --git a/app/tools/application_tool.rb b/app/tools/application_tool.rb
index aca627b..c7a0240 100644
--- a/app/tools/application_tool.rb
+++ b/app/tools/application_tool.rb
@@ -59,12 +59,10 @@ def with_error_handling
end
def project_annotations(project)
- Annotation.joins(screenshot: { page: :project })
- .where(projects: { id: project.id })
- .includes(:user, :screenshot, :annotation_comments)
+ Api::V1::ProjectScope.annotations(project)
end
def serialize_annotation(annotation)
- annotation.as_api_json
+ Api::V1::ContractSerializer.annotation(annotation)
end
end
diff --git a/bin/check_coverage b/bin/check_coverage
new file mode 100755
index 0000000..80e08a4
--- /dev/null
+++ b/bin/check_coverage
@@ -0,0 +1,48 @@
+#!/usr/bin/env ruby
+# frozen_string_literal: true
+
+require "json"
+
+result_path = ARGV.fetch(0, "coverage/.resultset.json")
+minimum = Float(ENV.fetch("MIN_COVERAGE", "100"))
+
+abort "Coverage result not found: #{result_path}" unless File.exist?(result_path)
+
+resultset = JSON.parse(File.read(result_path))
+covered_lines = {}
+covered_branches = {}
+
+resultset.each_value do |result|
+ result.fetch("coverage").each do |file, coverage|
+ coverage.fetch("lines", []).each_with_index do |count, index|
+ next if count.nil?
+
+ key = [ file, index + 1 ]
+ covered_lines[key] ||= false
+ covered_lines[key] ||= count.positive?
+ end
+
+ coverage.fetch("branches", {}).each do |branch_id, branch_counts|
+ branch_counts.each do |edge_id, count|
+ key = [ file, branch_id, edge_id ]
+ covered_branches[key] ||= false
+ covered_branches[key] ||= count.positive?
+ end
+ end
+ end
+end
+
+abort "No instrumented Ruby lines found in #{result_path}" if covered_lines.empty?
+
+line_percent = covered_lines.values.count(true).fdiv(covered_lines.length) * 100
+branch_percent =
+ if covered_branches.empty?
+ 100.0
+ else
+ covered_branches.values.count(true).fdiv(covered_branches.length) * 100
+ end
+
+puts format("Line coverage: %.2f%%", line_percent)
+puts format("Branch coverage: %.2f%%", branch_percent)
+
+exit 1 if line_percent < minimum || branch_percent < minimum
diff --git a/cmd/screenote/main.go b/cmd/screenote/main.go
new file mode 100644
index 0000000..88a0810
--- /dev/null
+++ b/cmd/screenote/main.go
@@ -0,0 +1,12 @@
+package main
+
+import (
+ "context"
+ "os"
+
+ "github.com/ivankuznetsov/screenote/internal/cli"
+)
+
+func main() {
+ os.Exit(cli.Execute(context.Background(), os.Args[1:], os.Stdin, os.Stdout, os.Stderr))
+}
diff --git a/config/ci.rb b/config/ci.rb
index 5d6856d..4516918 100644
--- a/config/ci.rb
+++ b/config/ci.rb
@@ -10,7 +10,12 @@
step "Security: Gem audit", "bin/bundler-audit"
step "Security: Importmap vulnerability audit", "bin/importmap audit"
step "Security: Brakeman code analysis", "bin/brakeman --quiet --no-pager --exit-on-warn --exit-on-error"
- step "Tests: Rails", "bin/rails test"
+ if ENV["REQUIRE_COVERAGE"] == "true"
+ step "Tests: Rails coverage", "env COVERAGE=true bin/rails test && bin/check_coverage"
+ else
+ step "Tests: Rails", "bin/rails test"
+ end
+
step "Tests: Seeds", "env RAILS_ENV=test bin/rails db:seed:replant"
if File.exist?("go.mod")
diff --git a/config/routes.rb b/config/routes.rb
index 50d0074..c8bc8d3 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -46,9 +46,16 @@
namespace :api do
put "screenshots/:id/upload", to: "screenshot_uploads#update", as: :screenshot_upload
namespace :v1 do
+ resources :projects, only: [ :index ] do
+ resources :pages, only: [ :index ]
+ resources :screenshots, only: [ :index ]
+ end
resources :screenshots, only: [ :create ] do
resources :annotations, only: [ :index ]
end
+ resources :annotations, only: [ :show ] do
+ resources :comments, controller: "annotation_comments", only: [ :create ]
+ end
end
end
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..3e9fb4f
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,13 @@
+module github.com/ivankuznetsov/screenote
+
+go 1.26
+
+require (
+ github.com/BurntSushi/toml v1.5.0
+ github.com/spf13/cobra v1.10.1
+)
+
+require (
+ github.com/inconshreveable/mousetrap v1.1.0 // indirect
+ github.com/spf13/pflag v1.0.9 // indirect
+)
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..2f2391c
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,12 @@
+github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg=
+github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
+github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
+github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
+github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
+github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
+github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s=
+github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0=
+github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
+github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/internal/cli/annotation.go b/internal/cli/annotation.go
new file mode 100644
index 0000000..4e1e886
--- /dev/null
+++ b/internal/cli/annotation.go
@@ -0,0 +1,168 @@
+package cli
+
+import (
+ "context"
+ "errors"
+ "net/http"
+ "net/url"
+
+ "github.com/ivankuznetsov/screenote/internal/screenote"
+ "github.com/spf13/cobra"
+)
+
+// pageSize is the maximum number of records the REST API returns per request.
+const pageSize = 100
+
+func (a *app) annotationCommand() *cobra.Command {
+ cmd := &cobra.Command{Use: "annotation", Short: "Annotation commands", Args: rejectArgs, RunE: showHelp}
+
+ var screenshotID, status, viewport string
+ var limit, offset int
+ list := &cobra.Command{
+ Use: "list",
+ Short: "List annotations",
+ Args: rejectArgs,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ client, resolved, err := a.client()
+ if err != nil {
+ return err
+ }
+ filters := screenote.Query(map[string]string{
+ "status": status,
+ "viewport": viewport,
+ })
+ if screenshotID != "" {
+ raw, _, err := client.Annotations(cmd.Context(), screenshotID, screenote.WithLimitOffset(cloneValues(filters), limit, offset))
+ if err != nil {
+ return err
+ }
+ return writeRawJSON(a.stdout, raw)
+ }
+
+ project, err := a.projectID(cmd.Context(), client, resolved)
+ if err != nil {
+ return err
+ }
+ screenshots, err := allScreenshots(cmd.Context(), client, project)
+ if err != nil {
+ return err
+ }
+ // Aggregate every annotation across every screenshot first, so
+ // --limit/--offset and the reported total describe the whole set
+ // rather than a single per-screenshot slice.
+ annotations := make([]screenote.Annotation, 0)
+ for _, screenshot := range screenshots {
+ response, err := allAnnotations(cmd.Context(), client, intString(screenshot.ID), filters)
+ if err != nil {
+ // A screenshot deleted between the list and the per-screenshot
+ // fetch returns 404; skip it and keep aggregating the rest.
+ // Every other failure (auth, rate limit, 5xx, network) must
+ // propagate so the listing never seals a partial result as
+ // complete.
+ var apiErr *screenote.Error
+ if errors.As(err, &apiErr) && apiErr.StatusCode == http.StatusNotFound {
+ continue
+ }
+ return err
+ }
+ annotations = append(annotations, response...)
+ }
+ total := len(annotations)
+ return writeJSON(a.stdout, map[string]any{
+ "annotations": pageAnnotations(annotations, limit, offset),
+ "pagination": map[string]int{
+ "total": total,
+ "limit": limit,
+ "offset": offset,
+ },
+ })
+ },
+ }
+ list.Flags().StringVar(&screenshotID, "screenshot", "", "Screenshot ID")
+ list.Flags().StringVar(&status, "status", "", "Annotation status")
+ list.Flags().StringVar(&viewport, "viewport", "", "Viewport")
+ list.Flags().IntVar(&limit, "limit", 50, "Maximum results")
+ list.Flags().IntVar(&offset, "offset", 0, "Results to skip")
+
+ var annotationID string
+ get := &cobra.Command{
+ Use: "get",
+ Short: "Get annotation details",
+ Args: rejectArgs,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ if annotationID == "" {
+ return missingFlag("annotation")
+ }
+ client, _, err := a.client()
+ if err != nil {
+ return err
+ }
+ raw, err := client.Annotation(cmd.Context(), annotationID)
+ if err != nil {
+ return err
+ }
+ return writeRawJSON(a.stdout, raw)
+ },
+ }
+ get.Flags().StringVar(&annotationID, "annotation", "", "Annotation ID")
+
+ cmd.AddCommand(list, get)
+ return cmd
+}
+
+// allScreenshots pages through every screenshot in a project so the aggregate
+// annotation listing is never silently capped at the first page.
+func allScreenshots(ctx context.Context, client *screenote.Client, project string) ([]screenote.Screenshot, error) {
+ all := make([]screenote.Screenshot, 0)
+ for offset := 0; ; offset += pageSize {
+ _, response, err := client.Screenshots(ctx, project, screenote.WithLimitOffset(screenote.Query(nil), pageSize, offset))
+ if err != nil {
+ return nil, err
+ }
+ all = append(all, response.Screenshots...)
+ if len(response.Screenshots) < pageSize {
+ return all, nil
+ }
+ }
+}
+
+// allAnnotations pages through every annotation for a single screenshot,
+// applying the shared status/viewport filters.
+func allAnnotations(ctx context.Context, client *screenote.Client, screenshot string, filters url.Values) ([]screenote.Annotation, error) {
+ all := make([]screenote.Annotation, 0)
+ for offset := 0; ; offset += pageSize {
+ _, response, err := client.Annotations(ctx, screenshot, screenote.WithLimitOffset(cloneValues(filters), pageSize, offset))
+ if err != nil {
+ return nil, err
+ }
+ all = append(all, response.Annotations...)
+ if len(response.Annotations) < pageSize {
+ return all, nil
+ }
+ }
+}
+
+// pageAnnotations applies the aggregate --limit/--offset window in memory.
+func pageAnnotations(items []screenote.Annotation, limit, offset int) []screenote.Annotation {
+ if offset < 0 {
+ offset = 0
+ }
+ if offset >= len(items) {
+ return []screenote.Annotation{}
+ }
+ items = items[offset:]
+ if limit > 0 && limit < len(items) {
+ items = items[:limit]
+ }
+ return items
+}
+
+// cloneValues copies query values so per-request limit/offset mutations do not
+// leak into the shared filter set.
+func cloneValues(values url.Values) url.Values {
+ out := url.Values{}
+ for key, vals := range values {
+ out[key] = append([]string(nil), vals...)
+ }
+ return out
+}
diff --git a/internal/cli/commands_test.go b/internal/cli/commands_test.go
new file mode 100644
index 0000000..4d3f6d6
--- /dev/null
+++ b/internal/cli/commands_test.go
@@ -0,0 +1,285 @@
+package cli
+
+import (
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "testing"
+)
+
+func TestScreenshotListSendsFilters(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/api/v1/projects/7/screenshots" {
+ t.Fatalf("path=%s", r.URL.Path)
+ }
+ if r.URL.Query().Get("page_id") != "9" || r.URL.Query().Get("status") != "ready" {
+ t.Fatalf("query=%s", r.URL.RawQuery)
+ }
+ _, _ = w.Write([]byte(`{"screenshots":[],"pagination":{"total":0,"limit":10,"offset":2}}`))
+ }))
+ defer server.Close()
+
+ stdout, stderr, code := runCLI(t, []string{"--base-url", server.URL, "--api-key", "key", "--project", "7", "screenshot", "list", "--page", "9", "--status", "ready", "--limit", "10", "--offset", "2"}, "")
+ if code != ExitOK {
+ t.Fatalf("code=%d stderr=%s", code, stderr)
+ }
+ if !strings.Contains(stdout, `"screenshots"`) {
+ t.Fatalf("stdout=%s", stdout)
+ }
+}
+
+func TestAnnotationGetPath(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet || r.URL.Path != "/api/v1/annotations/5" {
+ t.Fatalf("%s %s", r.Method, r.URL.Path)
+ }
+ _, _ = w.Write([]byte(`{"id":5}`))
+ }))
+ defer server.Close()
+
+ stdout, stderr, code := runCLI(t, []string{"--base-url", server.URL, "--api-key", "key", "annotation", "get", "--annotation", "5"}, "")
+ if code != ExitOK {
+ t.Fatalf("code=%d stderr=%s", code, stderr)
+ }
+ if !strings.Contains(stdout, `"id":5`) {
+ t.Fatalf("stdout=%s", stdout)
+ }
+}
+
+func TestAnnotationListPath(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet || r.URL.Path != "/api/v1/screenshots/4/annotations" {
+ t.Fatalf("%s %s", r.Method, r.URL.Path)
+ }
+ if r.URL.Query().Get("status") != "open" || r.URL.Query().Get("viewport") != "mobile" {
+ t.Fatalf("query=%s", r.URL.RawQuery)
+ }
+ _, _ = w.Write([]byte(`{"annotations":[]}`))
+ }))
+ defer server.Close()
+
+ _, stderr, code := runCLI(t, []string{"--base-url", server.URL, "--api-key", "key", "annotation", "list", "--screenshot", "4", "--status", "open", "--viewport", "mobile"}, "")
+ if code != ExitOK {
+ t.Fatalf("code=%d stderr=%s", code, stderr)
+ }
+}
+
+func TestUnknownFlagExitsUsage(t *testing.T) {
+ stdout, stderr, code := runCLI(t, []string{"--base-url", "http://example.test", "--api-key", "key", "project", "list", "--nope"}, "")
+ if code != ExitUsage {
+ t.Fatalf("code=%d stdout=%s stderr=%s", code, stdout, stderr)
+ }
+ if !strings.Contains(stderr, "invalid_flag") {
+ t.Fatalf("stderr=%s", stderr)
+ }
+}
+
+func TestScreenshotCreateDerivesContentType(t *testing.T) {
+ contentTypes := make(chan string, 1)
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if err := r.ParseMultipartForm(1024); err != nil {
+ t.Fatal(err)
+ }
+ files := r.MultipartForm.File["image"]
+ if len(files) == 0 {
+ t.Fatal("no image part")
+ }
+ contentTypes <- files[0].Header.Get("Content-Type")
+ _, _ = w.Write([]byte(`{"screenshot_id":9}`))
+ }))
+ defer server.Close()
+
+ dir := t.TempDir()
+ path := filepath.Join(dir, "shot.png")
+ if err := os.WriteFile(path, []byte("png-bytes"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+
+ _, stderr, code := runCLI(t, []string{"--base-url", server.URL, "--api-key", "key", "screenshot", "create", "--title", "Home", "--file", path}, "")
+ if code != ExitOK {
+ t.Fatalf("code=%d stderr=%s", code, stderr)
+ }
+ if got := <-contentTypes; got != "image/png" {
+ t.Fatalf("content type=%q", got)
+ }
+}
+
+func TestScreenshotCreateStdinContentTypeFallback(t *testing.T) {
+ contentTypes := make(chan string, 1)
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if err := r.ParseMultipartForm(1024); err != nil {
+ t.Fatal(err)
+ }
+ files := r.MultipartForm.File["image"]
+ if len(files) == 0 {
+ t.Fatal("no image part")
+ }
+ contentTypes <- files[0].Header.Get("Content-Type")
+ _, _ = w.Write([]byte(`{"screenshot_id":9}`))
+ }))
+ defer server.Close()
+
+ _, stderr, code := runCLI(t, []string{"--base-url", server.URL, "--api-key", "key", "screenshot", "create", "--title", "Home"}, "png-bytes")
+ if code != ExitOK {
+ t.Fatalf("code=%d stderr=%s", code, stderr)
+ }
+ if got := <-contentTypes; got != "application/octet-stream" {
+ t.Fatalf("content type=%q", got)
+ }
+}
+
+func TestCommentAddPathAndBody(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost || r.URL.Path != "/api/v1/annotations/5/comments" {
+ t.Fatalf("%s %s", r.Method, r.URL.Path)
+ }
+ if err := r.ParseForm(); err != nil {
+ t.Fatal(err)
+ }
+ if r.Form.Get("body") != "hello" {
+ t.Fatalf("body=%q", r.Form.Get("body"))
+ }
+ _, _ = w.Write([]byte(`{"success":true}`))
+ }))
+ defer server.Close()
+
+ _, stderr, code := runCLI(t, []string{"--base-url", server.URL, "--api-key", "key", "comment", "add", "--annotation", "5", "--body", "hello"}, "")
+ if code != ExitOK {
+ t.Fatalf("code=%d stderr=%s", code, stderr)
+ }
+}
+
+// aggregateServer serves the project-wide `annotation list` aggregate path:
+// two pages of screenshots, per-screenshot annotation paging, and a
+// configurable failure for one screenshot ID.
+func aggregateServer(t *testing.T, failID int, failStatus int) *httptest.Server {
+ t.Helper()
+ // annotationCount maps a screenshot ID to its total annotation count.
+ annotationCount := func(sid int) int {
+ switch sid {
+ case 1:
+ return 150 // spans two annotation pages (100 + 50)
+ default:
+ return 1
+ }
+ }
+ return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ switch {
+ case r.URL.Path == "/api/v1/projects/7/screenshots":
+ offset, _ := strconv.Atoi(r.URL.Query().Get("offset"))
+ var ids []int
+ if offset == 0 {
+ for id := 1; id <= 100; id++ { // full page -> forces a second request
+ ids = append(ids, id)
+ }
+ } else {
+ ids = []int{101, 102}
+ }
+ parts := make([]string, 0, len(ids))
+ for _, id := range ids {
+ parts = append(parts, fmt.Sprintf(`{"id":%d}`, id))
+ }
+ fmt.Fprintf(w, `{"screenshots":[%s],"pagination":{"total":102,"limit":100,"offset":%d}}`, strings.Join(parts, ","), offset)
+ case strings.HasPrefix(r.URL.Path, "/api/v1/screenshots/") && strings.HasSuffix(r.URL.Path, "/annotations"):
+ trimmed := strings.TrimSuffix(strings.TrimPrefix(r.URL.Path, "/api/v1/screenshots/"), "/annotations")
+ sid, err := strconv.Atoi(trimmed)
+ if err != nil {
+ t.Fatalf("bad screenshot id %q", trimmed)
+ }
+ if sid == failID {
+ w.WriteHeader(failStatus)
+ _, _ = w.Write([]byte(`{"error":"boom","code":"boom"}`))
+ return
+ }
+ offset, _ := strconv.Atoi(r.URL.Query().Get("offset"))
+ total := annotationCount(sid)
+ parts := make([]string, 0)
+ for i := offset; i < total && i < offset+pageSize; i++ {
+ parts = append(parts, fmt.Sprintf(`{"id":%d,"screenshot_id":%d}`, sid*1000+i, sid))
+ }
+ fmt.Fprintf(w, `{"annotations":[%s],"pagination":{"total":%d,"limit":%d,"offset":%d}}`, strings.Join(parts, ","), total, pageSize, offset)
+ default:
+ t.Fatalf("unexpected path %s", r.URL.Path)
+ }
+ }))
+}
+
+func TestAnnotationListAggregatesAcrossScreenshots(t *testing.T) {
+ // Screenshot 2 is deleted between list and fetch (404) and must be
+ // silently skipped: 150 (sid 1) + 0 (sid 2) + 100 (sid 3..102) = 250.
+ server := aggregateServer(t, 2, http.StatusNotFound)
+ defer server.Close()
+
+ stdout, stderr, code := runCLI(t, []string{"--base-url", server.URL, "--api-key", "key", "--project", "7", "annotation", "list", "--limit", "10", "--offset", "5"}, "")
+ if code != ExitOK {
+ t.Fatalf("code=%d stderr=%s", code, stderr)
+ }
+
+ var payload struct {
+ Annotations []struct {
+ ID int `json:"id"`
+ } `json:"annotations"`
+ Pagination struct {
+ Total int `json:"total"`
+ Limit int `json:"limit"`
+ Offset int `json:"offset"`
+ } `json:"pagination"`
+ }
+ if err := json.Unmarshal([]byte(stdout), &payload); err != nil {
+ t.Fatalf("stdout=%s err=%v", stdout, err)
+ }
+ if payload.Pagination.Total != 250 {
+ t.Fatalf("total=%d want 250", payload.Pagination.Total)
+ }
+ if len(payload.Annotations) != 10 {
+ t.Fatalf("window len=%d want 10", len(payload.Annotations))
+ }
+ if payload.Pagination.Limit != 10 || payload.Pagination.Offset != 5 {
+ t.Fatalf("pagination=%+v", payload.Pagination)
+ }
+}
+
+func TestAnnotationListPropagatesServerError(t *testing.T) {
+ // A 500 from one screenshot must fail the whole listing rather than seal
+ // a partial result as complete.
+ server := aggregateServer(t, 2, http.StatusInternalServerError)
+ defer server.Close()
+
+ _, _, code := runCLI(t, []string{"--base-url", server.URL, "--api-key", "key", "--project", "7", "annotation", "list"}, "")
+ if code == ExitOK {
+ t.Fatalf("expected non-zero exit for server error, got %d", code)
+ }
+}
+
+func TestUnknownCommandExitsUsage(t *testing.T) {
+ stdout, stderr, code := runCLI(t, []string{"--base-url", "http://example.test", "--api-key", "key", "bogus"}, "")
+ if code != ExitUsage {
+ t.Fatalf("code=%d stdout=%s stderr=%s", code, stdout, stderr)
+ }
+ var payload map[string]string
+ if err := json.Unmarshal([]byte(stderr), &payload); err != nil {
+ t.Fatal(err)
+ }
+ if payload["code"] != "unexpected_arguments" {
+ t.Fatalf("payload=%#v", payload)
+ }
+}
+
+func TestUnknownSubcommandExitsUsage(t *testing.T) {
+ _, _, code := runCLI(t, []string{"--base-url", "http://example.test", "--api-key", "key", "project", "bogus"}, "")
+ if code != ExitUsage {
+ t.Fatalf("code=%d", code)
+ }
+}
+
+func TestStrayPositionalArgExitsUsage(t *testing.T) {
+ _, _, code := runCLI(t, []string{"--base-url", "http://example.test", "--api-key", "key", "project", "list", "extra"}, "")
+ if code != ExitUsage {
+ t.Fatalf("code=%d", code)
+ }
+}
diff --git a/internal/cli/comment.go b/internal/cli/comment.go
new file mode 100644
index 0000000..d6b0658
--- /dev/null
+++ b/internal/cli/comment.go
@@ -0,0 +1,35 @@
+package cli
+
+import "github.com/spf13/cobra"
+
+func (a *app) commentCommand() *cobra.Command {
+ cmd := &cobra.Command{Use: "comment", Short: "Comment commands", Args: rejectArgs, RunE: showHelp}
+
+ var annotationID, body string
+ add := &cobra.Command{
+ Use: "add",
+ Short: "Add an annotation comment",
+ Args: rejectArgs,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ if annotationID == "" {
+ return missingFlag("annotation")
+ }
+ if body == "" {
+ return missingFlag("body")
+ }
+ client, _, err := a.client()
+ if err != nil {
+ return err
+ }
+ raw, err := client.AddComment(cmd.Context(), annotationID, body)
+ if err != nil {
+ return err
+ }
+ return writeRawJSON(a.stdout, raw)
+ },
+ }
+ add.Flags().StringVar(&annotationID, "annotation", "", "Annotation ID")
+ add.Flags().StringVar(&body, "body", "", "Comment body")
+ cmd.AddCommand(add)
+ return cmd
+}
diff --git a/internal/cli/config.go b/internal/cli/config.go
new file mode 100644
index 0000000..14f1d8c
--- /dev/null
+++ b/internal/cli/config.go
@@ -0,0 +1,54 @@
+package cli
+
+import (
+ appconfig "github.com/ivankuznetsov/screenote/internal/config"
+ "github.com/spf13/cobra"
+)
+
+func (a *app) configCommand() *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "config",
+ Short: "Print resolved configuration",
+ Args: rejectArgs,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ resolved, err := a.resolvedConfig()
+ if err != nil {
+ return err
+ }
+ return writeJSON(a.stdout, resolved)
+ },
+ }
+
+ var setValues appconfig.Values
+ set := &cobra.Command{
+ Use: "set",
+ Short: "Write configuration values",
+ Args: rejectArgs,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ path := defaultConfigPath(a.configPath)
+ values, err := appconfig.LoadExpanded(path)
+ if err != nil {
+ return err
+ }
+ if setValues.APIKey != "" {
+ values.APIKey = setValues.APIKey
+ }
+ if setValues.BaseURL != "" {
+ values.BaseURL = setValues.BaseURL
+ }
+ if setValues.Project != "" {
+ values.Project = setValues.Project
+ }
+ if err := appconfig.Save(path, values); err != nil {
+ return err
+ }
+ return writeJSON(a.stdout, map[string]any{"ok": true, "path": path})
+ },
+ }
+ set.Flags().StringVar(&setValues.APIKey, "api-key", "", "API key to write")
+ set.Flags().StringVar(&setValues.BaseURL, "base-url", "", "Base URL to write")
+ set.Flags().StringVar(&setValues.Project, "project", "", "Project ID to write")
+ cmd.AddCommand(set)
+
+ return cmd
+}
diff --git a/internal/cli/errors.go b/internal/cli/errors.go
new file mode 100644
index 0000000..d459224
--- /dev/null
+++ b/internal/cli/errors.go
@@ -0,0 +1,94 @@
+package cli
+
+import (
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+
+ "github.com/ivankuznetsov/screenote/internal/screenote"
+ "github.com/spf13/cobra"
+)
+
+const (
+ ExitOK = 0
+ ExitGeneric = 1
+ ExitUsage = 2
+ ExitAuth = 3
+ ExitNotFound = 4
+ ExitRateLimited = 5
+)
+
+type cliError struct {
+ Code string
+ Message string
+ Exit int
+}
+
+func (e *cliError) Error() string { return e.Message }
+
+func usageError(code, message string) error {
+ return &cliError{Code: code, Message: message, Exit: ExitUsage}
+}
+
+func writeError(w io.Writer, err error) int {
+ code := "internal_error"
+ message := err.Error()
+ exitCode := ExitGeneric
+
+ var ce *cliError
+ if errors.As(err, &ce) {
+ code = ce.Code
+ message = ce.Message
+ exitCode = ce.Exit
+ } else {
+ var se *screenote.Error
+ if errors.As(err, &se) {
+ code = se.Code
+ message = se.Message
+ exitCode = exitForHTTP(se.StatusCode)
+ }
+ }
+
+ _ = json.NewEncoder(w).Encode(map[string]string{
+ "error": message,
+ "code": code,
+ })
+ return exitCode
+}
+
+func exitForHTTP(status int) int {
+ switch status {
+ case http.StatusUnauthorized, http.StatusForbidden:
+ return ExitAuth
+ case http.StatusNotFound:
+ return ExitNotFound
+ case http.StatusTooManyRequests:
+ return ExitRateLimited
+ default:
+ return ExitGeneric
+ }
+}
+
+func missingFlag(name string) error {
+ return usageError("missing_"+name, fmt.Sprintf("--%s is required", name))
+}
+
+// rejectArgs maps stray positional args and unknown subcommands to a stable
+// usage error (exit 2) instead of letting Cobra surface them as a generic
+// failure. Assign it as every command's Args validator.
+func rejectArgs(cmd *cobra.Command, args []string) error {
+ if len(args) == 0 {
+ return nil
+ }
+ return usageError("unexpected_arguments", fmt.Sprintf("unknown command %q for %q", args[0], cmd.CommandPath()))
+}
+
+// showHelp is the RunE for pure grouping commands. Making them runnable ensures
+// Cobra reaches the rejectArgs validator (it short-circuits to help before
+// validating args on non-runnable commands), so an unknown subcommand exits 2
+// while a bare invocation still prints help.
+func showHelp(cmd *cobra.Command, _ []string) error {
+ return cmd.Help()
+}
diff --git a/internal/cli/page.go b/internal/cli/page.go
new file mode 100644
index 0000000..5fe4036
--- /dev/null
+++ b/internal/cli/page.go
@@ -0,0 +1,28 @@
+package cli
+
+import "github.com/spf13/cobra"
+
+func (a *app) pageCommand() *cobra.Command {
+ cmd := &cobra.Command{Use: "page", Short: "Page commands", Args: rejectArgs, RunE: showHelp}
+ cmd.AddCommand(&cobra.Command{
+ Use: "list",
+ Short: "List pages",
+ Args: rejectArgs,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ client, resolved, err := a.client()
+ if err != nil {
+ return err
+ }
+ project, err := a.projectID(cmd.Context(), client, resolved)
+ if err != nil {
+ return err
+ }
+ raw, err := client.Pages(cmd.Context(), project)
+ if err != nil {
+ return err
+ }
+ return writeRawJSON(a.stdout, raw)
+ },
+ })
+ return cmd
+}
diff --git a/internal/cli/project.go b/internal/cli/project.go
new file mode 100644
index 0000000..f601e2f
--- /dev/null
+++ b/internal/cli/project.go
@@ -0,0 +1,24 @@
+package cli
+
+import "github.com/spf13/cobra"
+
+func (a *app) projectCommand() *cobra.Command {
+ cmd := &cobra.Command{Use: "project", Short: "Project commands", Args: rejectArgs, RunE: showHelp}
+ cmd.AddCommand(&cobra.Command{
+ Use: "list",
+ Short: "List projects",
+ Args: rejectArgs,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ client, _, err := a.client()
+ if err != nil {
+ return err
+ }
+ raw, _, err := client.Projects(cmd.Context())
+ if err != nil {
+ return err
+ }
+ return writeRawJSON(a.stdout, raw)
+ },
+ })
+ return cmd
+}
diff --git a/internal/cli/root.go b/internal/cli/root.go
new file mode 100644
index 0000000..56a7eb8
--- /dev/null
+++ b/internal/cli/root.go
@@ -0,0 +1,136 @@
+package cli
+
+import (
+ "context"
+ "encoding/json"
+ "io"
+ "net/http"
+ "strconv"
+
+ appconfig "github.com/ivankuznetsov/screenote/internal/config"
+ "github.com/ivankuznetsov/screenote/internal/screenote"
+ "github.com/spf13/cobra"
+)
+
+type app struct {
+ stdin io.Reader
+ stdout io.Writer
+ stderr io.Writer
+ httpClient *http.Client
+ flags appconfig.Values
+ configPath string
+ interactive bool
+}
+
+func Execute(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) int {
+ a := &app{stdin: stdin, stdout: stdout, stderr: stderr}
+ cmd := a.rootCommand(ctx)
+ cmd.SetArgs(args)
+ if err := cmd.ExecuteContext(ctx); err != nil {
+ return writeError(stderr, err)
+ }
+ return ExitOK
+}
+
+func NewTestCommand(ctx context.Context, stdin io.Reader, stdout, stderr io.Writer, httpClient *http.Client) *cobra.Command {
+ a := &app{stdin: stdin, stdout: stdout, stderr: stderr, httpClient: httpClient}
+ return a.rootCommand(ctx)
+}
+
+func (a *app) rootCommand(_ context.Context) *cobra.Command {
+ root := &cobra.Command{
+ Use: "screenote",
+ Short: "Screenote REST CLI",
+ Args: rejectArgs,
+ RunE: showHelp,
+ SilenceUsage: true,
+ SilenceErrors: true,
+ }
+ root.SetFlagErrorFunc(func(_ *cobra.Command, err error) error {
+ return usageError("invalid_flag", err.Error())
+ })
+ root.PersistentFlags().StringVar(&a.flags.APIKey, "api-key", "", "Screenote project API key")
+ root.PersistentFlags().StringVar(&a.flags.BaseURL, "base-url", "", "Screenote base URL")
+ root.PersistentFlags().StringVar(&a.flags.Project, "project", "", "Screenote project ID")
+ root.PersistentFlags().StringVar(&a.configPath, "config", "", "Config file path")
+ root.PersistentFlags().BoolVar(&a.interactive, "interactive", false, "Allow interactive prompts")
+
+ root.AddCommand(
+ a.configCommand(),
+ a.projectCommand(),
+ a.pageCommand(),
+ a.screenshotCommand(),
+ a.annotationCommand(),
+ a.commentCommand(),
+ )
+ return root
+}
+
+func (a *app) resolvedConfig() (appconfig.Resolved, error) {
+ return appconfig.Resolve(appconfig.Options{
+ ConfigPath: a.configPath,
+ Flags: a.flags,
+ })
+}
+
+func (a *app) client() (*screenote.Client, appconfig.Resolved, error) {
+ resolved, err := a.resolvedConfig()
+ if err != nil {
+ return nil, resolved, err
+ }
+ if resolved.BaseURL == "" {
+ return nil, resolved, usageError("missing_base_url", "base URL is required; set --base-url, SCREENOTE_BASE_URL, or config base_url")
+ }
+ if resolved.APIKey == "" {
+ return nil, resolved, usageError("missing_api_key", "API key is required; set --api-key, SCREENOTE_API_KEY, or config api_key")
+ }
+
+ client, err := screenote.NewClient(resolved.BaseURL, resolved.APIKey, a.httpClient)
+ if err != nil {
+ return nil, resolved, usageError("invalid_base_url", err.Error())
+ }
+ return client, resolved, nil
+}
+
+func (a *app) projectID(ctx context.Context, client *screenote.Client, resolved appconfig.Resolved) (string, error) {
+ if resolved.Project != "" {
+ return resolved.Project, nil
+ }
+ _, projects, err := client.Projects(ctx)
+ if err != nil {
+ return "", err
+ }
+ if len(projects.Projects) == 0 {
+ return "", usageError("missing_project", "project is required and could not be inferred from the API key")
+ }
+ return intString(projects.Projects[0].ID), nil
+}
+
+func writeJSON(w io.Writer, value any) error {
+ return json.NewEncoder(w).Encode(value)
+}
+
+func writeRawJSON(w io.Writer, raw json.RawMessage) error {
+ if len(raw) == 0 {
+ raw = []byte("{}")
+ }
+ _, err := w.Write(raw)
+ if err != nil {
+ return err
+ }
+ if raw[len(raw)-1] != '\n' {
+ _, err = io.WriteString(w, "\n")
+ }
+ return err
+}
+
+func intString(id int) string {
+ return strconv.Itoa(id)
+}
+
+func defaultConfigPath(path string) string {
+ if path != "" {
+ return path
+ }
+ return appconfig.DefaultConfigPath
+}
diff --git a/internal/cli/root_test.go b/internal/cli/root_test.go
new file mode 100644
index 0000000..0c03759
--- /dev/null
+++ b/internal/cli/root_test.go
@@ -0,0 +1,126 @@
+package cli
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+)
+
+func TestProjectListCommand(t *testing.T) {
+ requests := make(chan *http.Request, 1)
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ requests <- r
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(`{"projects":[{"id":1,"name":"Demo"}]}`))
+ }))
+ defer server.Close()
+
+ stdout, stderr, code := runCLI(t, []string{"--base-url", server.URL, "--api-key", "key", "project", "list"}, "")
+ if code != ExitOK {
+ t.Fatalf("code=%d stderr=%s", code, stderr)
+ }
+ if !strings.Contains(stdout, `"projects"`) {
+ t.Fatalf("stdout=%s", stdout)
+ }
+ req := <-requests
+ if req.Method != http.MethodGet || req.URL.Path != "/api/v1/projects" {
+ t.Fatalf("%s %s", req.Method, req.URL.Path)
+ }
+ if req.Header.Get("Authorization") != "Bearer key" {
+ t.Fatalf("Authorization=%q", req.Header.Get("Authorization"))
+ }
+}
+
+func TestScreenshotCreateReadsStdin(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/api/v1/screenshots" {
+ t.Fatalf("path=%s", r.URL.Path)
+ }
+ if err := r.ParseMultipartForm(1024); err != nil {
+ t.Fatal(err)
+ }
+ if r.FormValue("title") != "Home" {
+ t.Fatalf("title=%q", r.FormValue("title"))
+ }
+ file, _, err := r.FormFile("image")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer file.Close()
+ _, _ = w.Write([]byte(`{"screenshot_id":3}`))
+ }))
+ defer server.Close()
+
+ stdout, stderr, code := runCLI(t, []string{"--base-url", server.URL, "--api-key", "key", "screenshot", "create", "--title", "Home"}, "image")
+ if code != ExitOK {
+ t.Fatalf("code=%d stderr=%s", code, stderr)
+ }
+ if !strings.Contains(stdout, `"screenshot_id":3`) {
+ t.Fatalf("stdout=%s", stdout)
+ }
+}
+
+func TestPageListInfersProject(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ switch r.URL.Path {
+ case "/api/v1/projects":
+ _, _ = w.Write([]byte(`{"projects":[{"id":42,"name":"Demo"}]}`))
+ case "/api/v1/projects/42/pages":
+ _, _ = w.Write([]byte(`{"pages":[]}`))
+ default:
+ t.Fatalf("unexpected path %s", r.URL.Path)
+ }
+ }))
+ defer server.Close()
+
+ stdout, stderr, code := runCLI(t, []string{"--base-url", server.URL, "--api-key", "key", "page", "list"}, "")
+ if code != ExitOK {
+ t.Fatalf("code=%d stderr=%s", code, stderr)
+ }
+ if !strings.Contains(stdout, `"pages"`) {
+ t.Fatalf("stdout=%s", stdout)
+ }
+}
+
+func TestCommentAddValidatesBody(t *testing.T) {
+ stdout, stderr, code := runCLI(t, []string{"--base-url", "http://example.test", "--api-key", "key", "comment", "add", "--annotation", "1"}, "")
+ if code != ExitUsage {
+ t.Fatalf("code=%d stdout=%s stderr=%s", code, stdout, stderr)
+ }
+ var payload map[string]string
+ if err := json.Unmarshal([]byte(stderr), &payload); err != nil {
+ t.Fatal(err)
+ }
+ if payload["code"] != "missing_body" {
+ t.Fatalf("payload=%#v", payload)
+ }
+}
+
+func TestServerStatusExitCodes(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusTooManyRequests)
+ _, _ = w.Write([]byte(`{"error":"slow down","code":"rate_limited"}`))
+ }))
+ defer server.Close()
+
+ _, stderr, code := runCLI(t, []string{"--base-url", server.URL, "--api-key", "key", "project", "list"}, "")
+ if code != ExitRateLimited {
+ t.Fatalf("code=%d stderr=%s", code, stderr)
+ }
+}
+
+func runCLI(t *testing.T, args []string, stdin string) (string, string, int) {
+ t.Helper()
+ var out, errOut bytes.Buffer
+ cmd := NewTestCommand(context.Background(), strings.NewReader(stdin), &out, &errOut, http.DefaultClient)
+ cmd.SetArgs(args)
+ if err := cmd.Execute(); err != nil {
+ code := writeError(&errOut, err)
+ return out.String(), errOut.String(), code
+ }
+ return out.String(), errOut.String(), ExitOK
+}
diff --git a/internal/cli/screenshot.go b/internal/cli/screenshot.go
new file mode 100644
index 0000000..591931c
--- /dev/null
+++ b/internal/cli/screenshot.go
@@ -0,0 +1,87 @@
+package cli
+
+import (
+ "mime"
+ "os"
+ "path/filepath"
+
+ "github.com/ivankuznetsov/screenote/internal/screenote"
+ "github.com/spf13/cobra"
+)
+
+func (a *app) screenshotCommand() *cobra.Command {
+ cmd := &cobra.Command{Use: "screenshot", Short: "Screenshot commands", Args: rejectArgs, RunE: showHelp}
+
+ var listPage, listStatus string
+ var limit, offset int
+ list := &cobra.Command{
+ Use: "list",
+ Short: "List screenshots",
+ Args: rejectArgs,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ client, resolved, err := a.client()
+ if err != nil {
+ return err
+ }
+ project, err := a.projectID(cmd.Context(), client, resolved)
+ if err != nil {
+ return err
+ }
+ query := screenote.WithLimitOffset(screenote.Query(map[string]string{
+ "page_id": listPage,
+ "status": listStatus,
+ }), limit, offset)
+ raw, _, err := client.Screenshots(cmd.Context(), project, query)
+ if err != nil {
+ return err
+ }
+ return writeRawJSON(a.stdout, raw)
+ },
+ }
+ list.Flags().StringVar(&listPage, "page", "", "Page ID")
+ list.Flags().StringVar(&listStatus, "status", "", "Screenshot status")
+ list.Flags().IntVar(&limit, "limit", 50, "Maximum results")
+ list.Flags().IntVar(&offset, "offset", 0, "Results to skip")
+
+ var title, pageValue, filePath string
+ create := &cobra.Command{
+ Use: "create",
+ Short: "Create a screenshot",
+ Args: rejectArgs,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ if title == "" {
+ return missingFlag("title")
+ }
+ client, _, err := a.client()
+ if err != nil {
+ return err
+ }
+
+ reader := a.stdin
+ filename := "stdin"
+ contentType := ""
+ if filePath != "" && filePath != "-" {
+ file, err := os.Open(filePath)
+ if err != nil {
+ return err
+ }
+ defer file.Close()
+ reader = file
+ filename = filePath
+ contentType = mime.TypeByExtension(filepath.Ext(filePath))
+ }
+
+ raw, err := client.CreateScreenshot(cmd.Context(), title, pageValue, filename, contentType, reader)
+ if err != nil {
+ return err
+ }
+ return writeRawJSON(a.stdout, raw)
+ },
+ }
+ create.Flags().StringVar(&title, "title", "", "Screenshot title")
+ create.Flags().StringVar(&pageValue, "page", "", "Page ID or page name")
+ create.Flags().StringVar(&filePath, "file", "-", "Image file path or - for stdin")
+
+ cmd.AddCommand(list, create)
+ return cmd
+}
diff --git a/internal/config/config.go b/internal/config/config.go
new file mode 100644
index 0000000..6eae0e7
--- /dev/null
+++ b/internal/config/config.go
@@ -0,0 +1,154 @@
+package config
+
+import (
+ "errors"
+ "os"
+ "path/filepath"
+
+ "github.com/BurntSushi/toml"
+)
+
+const DefaultConfigPath = "~/.config/screenote/config.toml"
+
+type Values struct {
+ APIKey string `toml:"api_key" json:"api_key,omitempty"`
+ BaseURL string `toml:"base_url" json:"base_url,omitempty"`
+ Project string `toml:"project" json:"project,omitempty"`
+}
+
+type Sources struct {
+ APIKey string `json:"api_key,omitempty"`
+ BaseURL string `json:"base_url,omitempty"`
+ Project string `json:"project,omitempty"`
+}
+
+type Resolved struct {
+ Values
+ Sources Sources `json:"sources"`
+}
+
+type Options struct {
+ ConfigPath string
+ Flags Values
+ Env map[string]string
+}
+
+func Resolve(options Options) (Resolved, error) {
+ env := func(key string) string { return os.Getenv(key) }
+ if options.Env != nil {
+ env = func(key string) string { return options.Env[key] }
+ }
+
+ path, err := ExpandPath(options.ConfigPath)
+ if err != nil {
+ return Resolved{}, err
+ }
+ if path == "" {
+ path, err = ExpandPath(DefaultConfigPath)
+ if err != nil {
+ return Resolved{}, err
+ }
+ }
+
+ fileValues, err := Load(path)
+ if err != nil {
+ return Resolved{}, err
+ }
+
+ resolved := Resolved{Values: fileValues}
+ if fileValues.APIKey != "" {
+ resolved.Sources.APIKey = "config"
+ }
+ if fileValues.BaseURL != "" {
+ resolved.Sources.BaseURL = "config"
+ }
+ if fileValues.Project != "" {
+ resolved.Sources.Project = "config"
+ }
+
+ apply := func(value, source string, target *string, sourceTarget *string) {
+ if value == "" {
+ return
+ }
+ *target = value
+ *sourceTarget = source
+ }
+
+ apply(env("SCREENOTE_API_KEY"), "env", &resolved.APIKey, &resolved.Sources.APIKey)
+ apply(env("SCREENOTE_BASE_URL"), "env", &resolved.BaseURL, &resolved.Sources.BaseURL)
+ apply(env("SCREENOTE_PROJECT"), "env", &resolved.Project, &resolved.Sources.Project)
+ apply(options.Flags.APIKey, "flag", &resolved.APIKey, &resolved.Sources.APIKey)
+ apply(options.Flags.BaseURL, "flag", &resolved.BaseURL, &resolved.Sources.BaseURL)
+ apply(options.Flags.Project, "flag", &resolved.Project, &resolved.Sources.Project)
+
+ return resolved, nil
+}
+
+func Load(path string) (Values, error) {
+ if path == "" {
+ return Values{}, nil
+ }
+
+ data, err := os.ReadFile(path)
+ if errors.Is(err, os.ErrNotExist) {
+ return Values{}, nil
+ }
+ if err != nil {
+ return Values{}, err
+ }
+
+ var values Values
+ if _, err := toml.Decode(string(data), &values); err != nil {
+ return Values{}, err
+ }
+ return values, nil
+}
+
+func LoadExpanded(path string) (Values, error) {
+ expanded, err := ExpandPath(path)
+ if err != nil {
+ return Values{}, err
+ }
+ return Load(expanded)
+}
+
+func Save(path string, values Values) error {
+ expanded, err := ExpandPath(path)
+ if err != nil {
+ return err
+ }
+ if expanded == "" {
+ expanded, err = ExpandPath(DefaultConfigPath)
+ if err != nil {
+ return err
+ }
+ }
+ if err := os.MkdirAll(filepath.Dir(expanded), 0o700); err != nil {
+ return err
+ }
+
+ f, err := os.OpenFile(expanded, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600)
+ if err != nil {
+ return err
+ }
+ defer f.Close()
+
+ return toml.NewEncoder(f).Encode(values)
+}
+
+func ExpandPath(path string) (string, error) {
+ if path == "" || path[0] != '~' {
+ return path, nil
+ }
+ home, err := os.UserHomeDir()
+ if err != nil {
+ return "", err
+ }
+ if path == "~" {
+ return home, nil
+ }
+ if len(path) > 1 && os.IsPathSeparator(path[1]) {
+ return filepath.Join(home, path[2:]), nil
+ }
+ return "", errors.New("unsupported config path")
+}
diff --git a/internal/config/config_test.go b/internal/config/config_test.go
new file mode 100644
index 0000000..765d676
--- /dev/null
+++ b/internal/config/config_test.go
@@ -0,0 +1,45 @@
+package config
+
+import (
+ "path/filepath"
+ "testing"
+)
+
+func TestResolvePrecedence(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "config.toml")
+ if err := Save(path, Values{APIKey: "file-key", BaseURL: "http://file", Project: "file-project"}); err != nil {
+ t.Fatal(err)
+ }
+
+ resolved, err := Resolve(Options{
+ ConfigPath: path,
+ Flags: Values{APIKey: "flag-key"},
+ Env: map[string]string{
+ "SCREENOTE_API_KEY": "env-key",
+ "SCREENOTE_BASE_URL": "http://env",
+ },
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if resolved.APIKey != "flag-key" || resolved.Sources.APIKey != "flag" {
+ t.Fatalf("api key = %q from %q", resolved.APIKey, resolved.Sources.APIKey)
+ }
+ if resolved.BaseURL != "http://env" || resolved.Sources.BaseURL != "env" {
+ t.Fatalf("base url = %q from %q", resolved.BaseURL, resolved.Sources.BaseURL)
+ }
+ if resolved.Project != "file-project" || resolved.Sources.Project != "config" {
+ t.Fatalf("project = %q from %q", resolved.Project, resolved.Sources.Project)
+ }
+}
+
+func TestLoadMissingConfig(t *testing.T) {
+ values, err := Load(filepath.Join(t.TempDir(), "missing.toml"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if values != (Values{}) {
+ t.Fatalf("values = %#v", values)
+ }
+}
diff --git a/internal/screenote/client.go b/internal/screenote/client.go
new file mode 100644
index 0000000..1433f4d
--- /dev/null
+++ b/internal/screenote/client.go
@@ -0,0 +1,229 @@
+package screenote
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "mime/multipart"
+ "net/http"
+ "net/textproto"
+ "net/url"
+ "path"
+ "strconv"
+ "strings"
+)
+
+type Client struct {
+ baseURL *url.URL
+ apiKey string
+ httpClient *http.Client
+}
+
+type Error struct {
+ StatusCode int
+ Code string
+ Message string
+}
+
+func (e *Error) Error() string {
+ if e.Message != "" {
+ return e.Message
+ }
+ return http.StatusText(e.StatusCode)
+}
+
+func NewClient(baseURL, apiKey string, httpClient *http.Client) (*Client, error) {
+ if baseURL == "" {
+ return nil, errors.New("base url is required")
+ }
+ parsed, err := url.Parse(baseURL)
+ if err != nil {
+ return nil, err
+ }
+ if parsed.Scheme == "" || parsed.Host == "" {
+ return nil, errors.New("base url must include scheme and host")
+ }
+ if httpClient == nil {
+ httpClient = http.DefaultClient
+ }
+ return &Client{baseURL: parsed, apiKey: apiKey, httpClient: httpClient}, nil
+}
+
+func (c *Client) Projects(ctx context.Context) (json.RawMessage, ProjectsResponse, error) {
+ var out ProjectsResponse
+ raw, err := c.doJSON(ctx, http.MethodGet, "/api/v1/projects", nil, nil, &out, nil)
+ return raw, out, err
+}
+
+func (c *Client) Pages(ctx context.Context, project string) (json.RawMessage, error) {
+ return c.doJSON(ctx, http.MethodGet, "/api/v1/projects/"+url.PathEscape(project)+"/pages", nil, nil, nil, nil)
+}
+
+func (c *Client) Screenshots(ctx context.Context, project string, query url.Values) (json.RawMessage, ScreenshotsResponse, error) {
+ var out ScreenshotsResponse
+ raw, err := c.doJSON(ctx, http.MethodGet, "/api/v1/projects/"+url.PathEscape(project)+"/screenshots", query, nil, &out, nil)
+ return raw, out, err
+}
+
+func (c *Client) CreateScreenshot(ctx context.Context, title, pageValue, filename, contentType string, r io.Reader) (json.RawMessage, error) {
+ if contentType == "" {
+ contentType = "application/octet-stream"
+ }
+ if filename == "" || filename == "-" {
+ filename = "stdin"
+ }
+
+ pr, pw := io.Pipe()
+ writer := multipart.NewWriter(pw)
+ headers := map[string]string{"Content-Type": writer.FormDataContentType()}
+
+ go func() {
+ var err error
+ defer func() {
+ closeErr := writer.Close()
+ if err == nil {
+ err = closeErr
+ }
+ _ = pw.CloseWithError(err)
+ }()
+
+ if title != "" {
+ if err = writer.WriteField("title", title); err != nil {
+ return
+ }
+ }
+ if pageValue != "" {
+ if err = writer.WriteField("page", pageValue); err != nil {
+ return
+ }
+ }
+
+ header := make(textproto.MIMEHeader)
+ header.Set("Content-Disposition", fmt.Sprintf(`form-data; name="image"; filename="%s"`, escapeQuotes(path.Base(filename))))
+ header.Set("Content-Type", contentType)
+ var part io.Writer
+ part, err = writer.CreatePart(header)
+ if err != nil {
+ return
+ }
+ _, err = io.Copy(part, r)
+ }()
+
+ return c.doJSON(ctx, http.MethodPost, "/api/v1/screenshots", nil, headers, nil, pr)
+}
+
+func (c *Client) Annotations(ctx context.Context, screenshot string, query url.Values) (json.RawMessage, AnnotationsResponse, error) {
+ var out AnnotationsResponse
+ raw, err := c.doJSON(ctx, http.MethodGet, "/api/v1/screenshots/"+url.PathEscape(screenshot)+"/annotations", query, nil, &out, nil)
+ return raw, out, err
+}
+
+func (c *Client) Annotation(ctx context.Context, id string) (json.RawMessage, error) {
+ return c.doJSON(ctx, http.MethodGet, "/api/v1/annotations/"+url.PathEscape(id), nil, nil, nil, nil)
+}
+
+func (c *Client) AddComment(ctx context.Context, annotation, body string) (json.RawMessage, error) {
+ form := url.Values{"body": []string{body}}
+ headers := map[string]string{"Content-Type": "application/x-www-form-urlencoded"}
+ return c.doJSON(ctx, http.MethodPost, "/api/v1/annotations/"+url.PathEscape(annotation)+"/comments", nil, headers, nil, strings.NewReader(form.Encode()))
+}
+
+func Query(params map[string]string) url.Values {
+ values := url.Values{}
+ for key, value := range params {
+ if value != "" {
+ values.Set(key, value)
+ }
+ }
+ return values
+}
+
+func WithLimitOffset(values url.Values, limit, offset int) url.Values {
+ if limit > 0 {
+ values.Set("limit", strconv.Itoa(limit))
+ }
+ if offset > 0 {
+ values.Set("offset", strconv.Itoa(offset))
+ }
+ return values
+}
+
+func (c *Client) doJSON(ctx context.Context, method, rawPath string, query url.Values, headers map[string]string, out any, body io.Reader) (json.RawMessage, error) {
+ u := *c.baseURL
+ u.Path = strings.TrimRight(c.baseURL.Path, "/") + rawPath
+ u.RawQuery = query.Encode()
+
+ req, err := http.NewRequestWithContext(ctx, method, u.String(), body)
+ if err != nil {
+ return nil, err
+ }
+ if c.apiKey != "" {
+ req.Header.Set("Authorization", "Bearer "+c.apiKey)
+ }
+ req.Header.Set("Accept", "application/json")
+ for key, value := range headers {
+ req.Header.Set(key, value)
+ }
+
+ resp, err := c.httpClient.Do(req)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+
+ raw, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, err
+ }
+ if resp.StatusCode < 200 || resp.StatusCode > 299 {
+ return raw, parseError(resp.StatusCode, raw)
+ }
+ if out != nil && len(raw) > 0 {
+ if err := json.Unmarshal(raw, out); err != nil {
+ return raw, err
+ }
+ }
+ return raw, nil
+}
+
+func parseError(status int, raw []byte) error {
+ var payload struct {
+ Error string `json:"error"`
+ Code string `json:"code"`
+ Message string `json:"message"`
+ }
+ _ = json.Unmarshal(raw, &payload)
+
+ message := payload.Error
+ if message == "" {
+ message = payload.Message
+ }
+ if message == "" {
+ message = http.StatusText(status)
+ }
+ code := payload.Code
+ if code == "" {
+ code = statusCode(status)
+ }
+
+ return &Error{StatusCode: status, Code: code, Message: message}
+}
+
+func statusCode(status int) string {
+ switch status {
+ case http.StatusUnauthorized, http.StatusForbidden:
+ return "unauthorized"
+ case http.StatusNotFound:
+ return "not_found"
+ case http.StatusTooManyRequests:
+ return "rate_limited"
+ default:
+ return fmt.Sprintf("http_%d", status)
+ }
+}
+
+func escapeQuotes(value string) string {
+ return strings.NewReplacer("\\", "\\\\", `"`, "\\\"").Replace(value)
+}
diff --git a/internal/screenote/client_test.go b/internal/screenote/client_test.go
new file mode 100644
index 0000000..13ee371
--- /dev/null
+++ b/internal/screenote/client_test.go
@@ -0,0 +1,74 @@
+package screenote
+
+import (
+ "context"
+ "encoding/json"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+)
+
+func TestClientSendsAuthAndParsesErrors(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Header.Get("Authorization") != "Bearer test-key" {
+ t.Fatalf("Authorization = %q", r.Header.Get("Authorization"))
+ }
+ http.Error(w, `{"error":"nope","code":"unauthorized"}`, http.StatusUnauthorized)
+ }))
+ defer server.Close()
+
+ client, err := NewClient(server.URL, "test-key", server.Client())
+ if err != nil {
+ t.Fatal(err)
+ }
+ _, _, err = client.Projects(context.Background())
+ if err == nil {
+ t.Fatal("expected error")
+ }
+ apiErr, ok := err.(*Error)
+ if !ok {
+ t.Fatalf("err = %T", err)
+ }
+ if apiErr.StatusCode != http.StatusUnauthorized || apiErr.Code != "unauthorized" {
+ t.Fatalf("apiErr = %#v", apiErr)
+ }
+}
+
+func TestClientCreateScreenshotMultipart(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost || r.URL.Path != "/api/v1/screenshots" {
+ t.Fatalf("%s %s", r.Method, r.URL.Path)
+ }
+ if err := r.ParseMultipartForm(1024); err != nil {
+ t.Fatal(err)
+ }
+ if got := r.FormValue("title"); got != "Home" {
+ t.Fatalf("title = %q", got)
+ }
+ file, header, err := r.FormFile("image")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer file.Close()
+ data, _ := io.ReadAll(file)
+ if header.Filename != "stdin.png" || string(data) != "png-data" {
+ t.Fatalf("file %q %q", header.Filename, string(data))
+ }
+ _ = json.NewEncoder(w).Encode(map[string]any{"screenshot_id": 7})
+ }))
+ defer server.Close()
+
+ client, err := NewClient(server.URL, "test-key", server.Client())
+ if err != nil {
+ t.Fatal(err)
+ }
+ raw, err := client.CreateScreenshot(context.Background(), "Home", "", "stdin.png", "image/png", strings.NewReader("png-data"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !strings.Contains(string(raw), `"screenshot_id":7`) {
+ t.Fatalf("raw = %s", raw)
+ }
+}
diff --git a/internal/screenote/types.go b/internal/screenote/types.go
new file mode 100644
index 0000000..2c39b9f
--- /dev/null
+++ b/internal/screenote/types.go
@@ -0,0 +1,92 @@
+package screenote
+
+type Project struct {
+ ID int `json:"id"`
+ Name string `json:"name"`
+ Role string `json:"role,omitempty"`
+ ScreenshotCount int `json:"screenshot_count"`
+ CreatedAt string `json:"created_at"`
+}
+
+type ProjectsResponse struct {
+ Projects []Project `json:"projects"`
+}
+
+type Page struct {
+ ID int `json:"id"`
+ Name string `json:"name"`
+ VersionCount int `json:"version_count"`
+ URL string `json:"url"`
+ CreatedAt string `json:"created_at"`
+}
+
+type Pagination struct {
+ Total int `json:"total"`
+ Limit int `json:"limit"`
+ Offset int `json:"offset"`
+}
+
+type ScreenshotImage struct {
+ ID int `json:"id"`
+ Viewport string `json:"viewport"`
+ Status string `json:"status"`
+ Width *int `json:"width"`
+ Height *int `json:"height"`
+ Attached bool `json:"attached"`
+}
+
+type Screenshot struct {
+ ID int `json:"id"`
+ Title string `json:"title"`
+ PageID int `json:"page_id"`
+ PageName string `json:"page_name"`
+ Status string `json:"status"`
+ AnnotationCount int `json:"annotation_count"`
+ UnresolvedCount int `json:"unresolved_count"`
+ AnnotateURL string `json:"annotate_url"`
+ Viewports []ScreenshotImage `json:"viewports"`
+ CreatedAt string `json:"created_at"`
+}
+
+type ScreenshotsResponse struct {
+ Screenshots []Screenshot `json:"screenshots"`
+ Pagination Pagination `json:"pagination"`
+}
+
+type Coordinates struct {
+ XPercent float64 `json:"x_percent"`
+ YPercent float64 `json:"y_percent"`
+ WidthPercent *float64 `json:"width_percent"`
+ HeightPercent *float64 `json:"height_percent"`
+}
+
+type Annotation struct {
+ ID int `json:"id"`
+ ScreenshotID int `json:"screenshot_id"`
+ Viewport string `json:"viewport"`
+ Type string `json:"type"`
+ Coordinates Coordinates `json:"coordinates"`
+ Comment string `json:"comment"`
+ Status string `json:"status"`
+ Author string `json:"author"`
+ CommentsCount int `json:"comments_count"`
+ CreatedAt string `json:"created_at"`
+ ScreenshotStatus string `json:"screenshot_status,omitempty"`
+ CroppedImageBase64 *string `json:"cropped_image_base64,omitempty"`
+ MIMEType string `json:"mime_type,omitempty"`
+ Comments []Comment `json:"comments,omitempty"`
+}
+
+type AnnotationsResponse struct {
+ Annotations []Annotation `json:"annotations"`
+ Pagination Pagination `json:"pagination"`
+}
+
+type Comment struct {
+ ID int `json:"id"`
+ AnnotationID int `json:"annotation_id,omitempty"`
+ Action string `json:"action"`
+ Body string `json:"body"`
+ Author string `json:"author"`
+ CreatedAt string `json:"created_at"`
+}
diff --git a/test/controllers/api/v1/annotation_comments_controller_test.rb b/test/controllers/api/v1/annotation_comments_controller_test.rb
new file mode 100644
index 0000000..1e2e48e
--- /dev/null
+++ b/test/controllers/api/v1/annotation_comments_controller_test.rb
@@ -0,0 +1,56 @@
+# frozen_string_literal: true
+
+require "test_helper"
+
+module Api
+ module V1
+ class AnnotationCommentsControllerTest < ActionDispatch::IntegrationTest
+ ALICE_TOKEN = "sk_proj_test_alice_key_000000000000000000000000"
+
+ test "creates an api-key-authored comment" do
+ annotation = annotations(:point_annotation)
+
+ assert_difference "AnnotationComment.count", 1 do
+ post api_v1_annotation_comments_path(annotation),
+ params: { body: "Taking a look" },
+ headers: auth_header(ALICE_TOKEN)
+ end
+
+ assert_response :created
+ body = response.parsed_body
+ assert body["success"]
+ assert_equal "Taking a look", body["comment"]["body"]
+ assert_equal "Alice's CI Key", body["comment"]["author"]
+ assert_equal api_keys(:alice_key), AnnotationComment.last.api_key
+ end
+
+ test "rejects empty comment body with validation code" do
+ assert_no_difference "AnnotationComment.count" do
+ post api_v1_annotation_comments_path(annotations(:point_annotation)),
+ params: { body: "" },
+ headers: auth_header(ALICE_TOKEN)
+ end
+
+ assert_response :unprocessable_entity
+ assert_equal "validation_failed", response.parsed_body["code"]
+ end
+
+ test "does not comment on annotations outside the key project" do
+ assert_no_difference "AnnotationComment.count" do
+ post api_v1_annotation_comments_path(annotations(:bob_annotation)),
+ params: { body: "Nope" },
+ headers: auth_header(ALICE_TOKEN)
+ end
+
+ assert_response :not_found
+ assert_equal "not_found", response.parsed_body["code"]
+ end
+
+ private
+
+ def auth_header(token)
+ { "Authorization" => "Bearer #{token}" }
+ end
+ end
+ end
+end
diff --git a/test/controllers/api/v1/annotations_controller_test.rb b/test/controllers/api/v1/annotations_controller_test.rb
index d23927b..6273198 100644
--- a/test/controllers/api/v1/annotations_controller_test.rb
+++ b/test/controllers/api/v1/annotations_controller_test.rb
@@ -19,6 +19,7 @@ class AnnotationsControllerTest < ActionDispatch::IntegrationTest
assert_response :success
body = response.parsed_body
assert body["annotations"].is_a?(Array), "Response should include annotations array"
+ assert body["pagination"].present?, "Response should include pagination"
end
test "filters annotations by status" do
@@ -32,6 +33,46 @@ class AnnotationsControllerTest < ActionDispatch::IntegrationTest
end
end
+ test "filters annotations by viewport and paginates" do
+ get api_v1_screenshot_annotations_path(@screenshot, viewport: "desktop", limit: 2, offset: 0),
+ headers: auth_header(ALICE_TOKEN)
+
+ assert_response :success
+ body = response.parsed_body
+ assert_equal 2, body["pagination"]["limit"]
+ body["annotations"].each do |annotation|
+ assert_equal "desktop", annotation["viewport"]
+ end
+ end
+
+ test "does not list annotations for another project screenshot" do
+ get api_v1_screenshot_annotations_path(screenshots(:bob_screenshot)),
+ headers: auth_header(ALICE_TOKEN)
+
+ assert_response :success
+ assert_empty response.parsed_body["annotations"]
+ end
+
+ test "gets annotation details with comments" do
+ get api_v1_annotation_path(annotations(:resolved_annotation)),
+ headers: auth_header(ALICE_TOKEN)
+
+ assert_response :success
+ body = response.parsed_body
+ assert_equal annotations(:resolved_annotation).id, body["id"]
+ assert_equal "ready", body["screenshot_status"]
+ assert_equal "image/png", body["mime_type"]
+ assert body["comments"].any? { |comment| comment["body"] == "Fixed the alignment issue" }
+ end
+
+ test "does not get annotation outside the key project" do
+ get api_v1_annotation_path(annotations(:bob_annotation)),
+ headers: auth_header(ALICE_TOKEN)
+
+ assert_response :not_found
+ assert_equal "not_found", response.parsed_body["code"]
+ end
+
test "returns 401 without authorization" do
get api_v1_screenshot_annotations_path(@screenshot)
diff --git a/test/controllers/api/v1/pages_controller_test.rb b/test/controllers/api/v1/pages_controller_test.rb
new file mode 100644
index 0000000..8c578f2
--- /dev/null
+++ b/test/controllers/api/v1/pages_controller_test.rb
@@ -0,0 +1,36 @@
+# frozen_string_literal: true
+
+require "test_helper"
+
+module Api
+ module V1
+ class PagesControllerTest < ActionDispatch::IntegrationTest
+ ALICE_TOKEN = "sk_proj_test_alice_key_000000000000000000000000"
+
+ test "lists pages with version counts for the key project" do
+ get api_v1_project_pages_path(projects(:alice_project)), headers: auth_header(ALICE_TOKEN)
+
+ assert_response :success
+ body = response.parsed_body
+ homepage = body["pages"].detect { |page| page["id"] == pages(:alice_page).id }
+ assert homepage
+ assert_equal "Homepage Design", homepage["name"]
+ assert_equal 1, homepage["version_count"]
+ assert homepage["url"].present?
+ end
+
+ test "rejects mismatched project id" do
+ get api_v1_project_pages_path(projects(:bob_project)), headers: auth_header(ALICE_TOKEN)
+
+ assert_response :forbidden
+ assert_equal "forbidden", response.parsed_body["code"]
+ end
+
+ private
+
+ def auth_header(token)
+ { "Authorization" => "Bearer #{token}" }
+ end
+ end
+ end
+end
diff --git a/test/controllers/api/v1/projects_controller_test.rb b/test/controllers/api/v1/projects_controller_test.rb
new file mode 100644
index 0000000..b53cd9d
--- /dev/null
+++ b/test/controllers/api/v1/projects_controller_test.rb
@@ -0,0 +1,35 @@
+# frozen_string_literal: true
+
+require "test_helper"
+
+module Api
+ module V1
+ class ProjectsControllerTest < ActionDispatch::IntegrationTest
+ ALICE_TOKEN = "sk_proj_test_alice_key_000000000000000000000000"
+
+ test "lists the project bound to the api key" do
+ get api_v1_projects_path, headers: auth_header(ALICE_TOKEN)
+
+ assert_response :success
+ body = response.parsed_body
+ assert_equal [ projects(:alice_project).id ], body["projects"].map { |project| project["id"] }
+ assert_equal "api_key", body["projects"].first["role"]
+ assert body["projects"].first.key?("screenshot_count")
+ end
+
+ test "returns stable unauthorized json without an api key" do
+ get api_v1_projects_path
+
+ assert_response :unauthorized
+ assert_equal "Invalid or missing API key", response.parsed_body["error"]
+ assert_equal "unauthorized", response.parsed_body["code"]
+ end
+
+ private
+
+ def auth_header(token)
+ { "Authorization" => "Bearer #{token}" }
+ end
+ end
+ end
+end
diff --git a/test/controllers/api/v1/screenshots_controller_test.rb b/test/controllers/api/v1/screenshots_controller_test.rb
index f1b41c2..aa17387 100644
--- a/test/controllers/api/v1/screenshots_controller_test.rb
+++ b/test/controllers/api/v1/screenshots_controller_test.rb
@@ -14,6 +14,27 @@ class ScreenshotsControllerTest < ActionDispatch::IntegrationTest
@image = fixture_file_upload("test_image.png", "image/png")
end
+ test "lists screenshots with filters and pagination" do
+ get api_v1_project_screenshots_path(@project, page_id: pages(:alice_page).id, status: "ready", limit: 1, offset: 0),
+ headers: auth_header(ALICE_TOKEN)
+
+ assert_response :success
+ body = response.parsed_body
+ assert_equal 1, body["screenshots"].length
+ assert_equal screenshots(:alice_screenshot).id, body["screenshots"].first["id"]
+ assert_equal "ready", body["screenshots"].first["status"]
+ assert_equal 1, body["pagination"]["limit"]
+ assert_equal 1, body["pagination"]["total"]
+ end
+
+ test "does not list screenshots outside the key project" do
+ get api_v1_project_screenshots_path(projects(:bob_project)),
+ headers: auth_header(ALICE_TOKEN)
+
+ assert_response :forbidden
+ assert_equal "forbidden", response.parsed_body["code"]
+ end
+
test "creates screenshot with valid image and auth" do
assert_difference "Screenshot.count", 1 do
post api_v1_screenshots_path,
@@ -25,6 +46,8 @@ class ScreenshotsControllerTest < ActionDispatch::IntegrationTest
body = response.parsed_body
assert body["screenshot_id"].present?, "Response should include screenshot_id"
assert body["annotate_url"].present?, "Response should include annotate_url"
+ assert_equal @project.pages.find_by(name: "Homepage").id, body["page_id"]
+ assert body["image"].present?, "Response should include image metadata"
screenshot = Screenshot.find(body["screenshot_id"])
assert_equal "Homepage", screenshot.title
@@ -32,12 +55,34 @@ class ScreenshotsControllerTest < ActionDispatch::IntegrationTest
assert_equal @project.id, screenshot.project.id, "Screenshot should belong to the project via page"
end
+ test "creates screenshot on the requested page id" do
+ page = pages(:alice_page)
+
+ post api_v1_screenshots_path,
+ params: { image: @image, title: "Requested Page", page_id: page.id },
+ headers: auth_header(ALICE_TOKEN)
+
+ assert_response :created
+ assert_equal page.id, Screenshot.find(response.parsed_body["screenshot_id"]).page_id
+ end
+
+ test "creates screenshot on the requested page name" do
+ post api_v1_screenshots_path,
+ params: { image: @image, title: "Upload Title", page: "Checkout" },
+ headers: auth_header(ALICE_TOKEN)
+
+ assert_response :created
+ screenshot = Screenshot.find(response.parsed_body["screenshot_id"])
+ assert_equal "Checkout", screenshot.page.name
+ end
+
test "returns 401 without authorization header" do
post api_v1_screenshots_path,
params: { image: @image, title: "Test" }
assert_response :unauthorized
assert_equal "Invalid or missing API key", response.parsed_body["error"]
+ assert_equal "unauthorized", response.parsed_body["code"]
end
test "returns 401 with invalid token" do
@@ -63,6 +108,25 @@ class ScreenshotsControllerTest < ActionDispatch::IntegrationTest
assert_response :unprocessable_entity
assert_equal "Image file is required", response.parsed_body["error"]
+ assert_equal "validation_failed", response.parsed_body["code"]
+ end
+
+ test "returns 422 when image is not an uploaded file" do
+ post api_v1_screenshots_path,
+ params: { image: "not-a-file", title: "Bad image" },
+ headers: auth_header(ALICE_TOKEN)
+
+ assert_response :unprocessable_entity
+ assert_equal "Image file is required", response.parsed_body["error"]
+ assert_equal "validation_failed", response.parsed_body["code"]
+ end
+
+ test "returns stable JSON for malformed pagination params" do
+ get api_v1_project_screenshots_path(@project, limit: { x: 1 }),
+ headers: auth_header(ALICE_TOKEN)
+
+ assert_response :success
+ assert_equal 50, response.parsed_body["pagination"]["limit"]
end
test "uses default title when none provided" do
diff --git a/test/fixtures/pages.yml b/test/fixtures/pages.yml
index f332879..df3777a 100644
--- a/test/fixtures/pages.yml
+++ b/test/fixtures/pages.yml
@@ -9,3 +9,7 @@ alice_page_pending:
bob_page:
name: "Bob's Screenshot"
project: bob_project
+
+demo_home:
+ name: "Home"
+ project: demo_project
diff --git a/test/fixtures/project_memberships.yml b/test/fixtures/project_memberships.yml
index 739f8dd..c45d922 100644
--- a/test/fixtures/project_memberships.yml
+++ b/test/fixtures/project_memberships.yml
@@ -17,3 +17,8 @@ bob_member_of_alice_project:
project: alice_project
user: bob
role: 0
+
+test_user_owns_demo_project:
+ project: demo_project
+ user: test_user
+ role: 1
diff --git a/test/fixtures/projects.yml b/test/fixtures/projects.yml
index b426d1d..398d8af 100644
--- a/test/fixtures/projects.yml
+++ b/test/fixtures/projects.yml
@@ -12,3 +12,8 @@ bob_project:
name: "Bob's Project"
description: "A test project for Bob"
user_id: <%= ActiveRecord::FixtureSet.identify(:bob) %>
+
+demo_project:
+ name: "Demo Project"
+ description: "A sample project for testing."
+ user_id: <%= ActiveRecord::FixtureSet.identify(:test_user) %>
diff --git a/test/fixtures/subscriptions.yml b/test/fixtures/subscriptions.yml
index c5e1cce..0a03468 100644
--- a/test/fixtures/subscriptions.yml
+++ b/test/fixtures/subscriptions.yml
@@ -11,3 +11,17 @@ bob_free:
stripe_customer_id: cus_bob_test_789
plan: 0
status: 0
+
+test_user_pro:
+ user: test_user
+ stripe_customer_id: cus_test_user_seed
+ stripe_subscription_id: sub_test_user_seed
+ plan: 1
+ status: 1
+ current_period_end: <%= 1.year.from_now %>
+
+free_user_free:
+ user: free_user
+ stripe_customer_id: cus_free_user_seed
+ plan: 0
+ status: 0
diff --git a/test/fixtures/users.yml b/test/fixtures/users.yml
index 3036772..868d6cb 100644
--- a/test/fixtures/users.yml
+++ b/test/fixtures/users.yml
@@ -17,3 +17,13 @@ admin:
email: <email>
password_digest: <%= BCrypt::Password.create("password123") %>
confirmed_at: <%= Time.current %>
+
+test_user:
+ email: <email>
+ password_digest: <%= BCrypt::Password.create("password") %>
+ confirmed_at: <%= Time.current %>
+
+free_user:
+ email: <email>
+ password_digest: <%= BCrypt::Password.create("password") %>
+ confirmed_at: <%= Time.current %>
diff --git a/test/mailers/notification_mailer_preview_test.rb b/test/mailers/notification_mailer_preview_test.rb
new file mode 100644
index 0000000..60e8783
--- /dev/null
+++ b/test/mailers/notification_mailer_preview_test.rb
@@ -0,0 +1,30 @@
+# frozen_string_literal: true
+
+require "test_helper"
+
+class NotificationMailerPreviewTest < ActionMailer::TestCase
+ test "resolution digest single preview renders expected content" do
+ email = NotificationMailerPreview.new.resolution_digest_single
+
+ assert_includes email.subject, "[Screenote]"
+ assert_includes email.subject, "annotation"
+ assert_includes email.body.to_s, "Feedback Resolved"
+ assert_includes email.body.to_s, "resolved"
+ end
+
+ test "resolution digest multiple preview renders expected content" do
+ email = NotificationMailerPreview.new.resolution_digest_multiple
+
+ assert_includes email.subject, "[Screenote]"
+ assert_includes email.body.to_s, "Feedback Resolved"
+ assert_includes email.body.to_s, "annotation"
+ end
+
+ test "resolution digest with reply preview renders reply content" do
+ email = NotificationMailerPreview.new.resolution_digest_with_reply
+
+ assert_includes email.subject, "[Screenote]"
+ assert_includes email.body.to_s, "Feedback Resolved"
+ assert_includes email.body.to_s, "Reply"
+ end
+end
diff --git a/test/system/annotations_test.rb b/test/system/annotations_test.rb
index e003e7d..de80eaa 100644
--- a/test/system/annotations_test.rb
+++ b/test/system/annotations_test.rb
@@ -34,7 +34,6 @@ class AnnotationsTest < ApplicationSystemTestCase
submit_annotation
wait_for_turbo
- assert_flash_notice "Annotation added."
assert_annotation_visible("First annotation from e2e test")
assert_annotation_count(1)
end
@@ -67,7 +66,6 @@ class AnnotationsTest < ApplicationSystemTestCase
resolve_annotation("Will be resolved")
wait_for_turbo
- assert_flash_notice "Annotation updated."
assert_annotation_resolved("Will be resolved")
end
@@ -81,7 +79,6 @@ class AnnotationsTest < ApplicationSystemTestCase
unresolve_annotation("Will be unresolved", reason: "Still broken on mobile")
wait_for_turbo
- assert_flash_notice "Annotation unresolved."
assert_annotation_open("Will be unresolved")
assert_thread_has_resolved_badge("Will be unresolved")
assert_thread_has_reopened_badge("Will be unresolved")
@@ -98,7 +95,6 @@ class AnnotationsTest < ApplicationSystemTestCase
end
wait_for_turbo
- assert_flash_notice "Annotation deleted."
assert_annotation_not_visible("Will be deleted")
end
diff --git a/test/system/api_upload_test.rb b/test/system/api_upload_test.rb
index 11abb21..e2771ac 100644
--- a/test/system/api_upload_test.rb
+++ b/test/system/api_upload_test.rb
@@ -43,7 +43,7 @@ class ApiUploadTest < ApplicationSystemTestCase
assert body["screenshot_id"].present?, "Response should include screenshot_id"
assert body["annotate_url"].present?, "Response should include annotate_url"
- visit body["annotate_url"]
+ visit_app_url body["annotate_url"]
assert_selector '[data-testid="screenshot-workspace"]', wait: 10
end
@@ -85,7 +85,7 @@ class ApiUploadTest < ApplicationSystemTestCase
private
def api_upload_screenshot(token:, image_path:, title: nil)
- uri = URI("#{Capybara.app_host}/api/v1/screenshots")
+ uri = URI("#{app_base_url}/api/v1/screenshots")
request = Net::HTTP::Post.new(uri)
request["Authorization"] = "Bearer #{token}" if token
diff --git a/test/system/application_system_test_case.rb b/test/system/application_system_test_case.rb
index 9a9d851..f2df980 100644
--- a/test/system/application_system_test_case.rb
+++ b/test/system/application_system_test_case.rb
@@ -2,6 +2,7 @@
require "test_helper"
require "capybara-playwright-driver"
+require "uri"
# System tests run against the dev server on port 3005 (must be running: bin/dev).
# Uses Capybara with the Playwright driver for real browser automation.
@@ -9,8 +10,12 @@
# API guidelines:
# - Prefer Capybara methods (find, assert_selector, fill_in, click_button) for most interactions.
# - Use with_playwright_page for hover, response interception, or low-level mouse/keyboard.
-Capybara.run_server = false
-Capybara.app_host = ENV.fetch("APP_HOST", "http://localhost:3005")
+if ENV["CAPYBARA_RUN_SERVER"] == "true"
+ Capybara.run_server = true
+else
+ Capybara.run_server = false
+ Capybara.app_host = ENV.fetch("APP_HOST", "http://localhost:3005")
+end
Capybara.default_max_wait_time = 15
Capybara.save_path = "tmp/capybara"
@@ -66,4 +71,16 @@ def assert_flash_notice(text)
def with_playwright_page(&block)
page.driver.with_playwright_page(&block)
end
+
+ def app_base_url
+ return Capybara.app_host if Capybara.app_host.present?
+
+ current_uri = URI.parse(page.current_url)
+ "#{current_uri.scheme}://#{current_uri.host}:#{current_uri.port}"
+ end
+
+ def visit_app_url(url)
+ uri = URI.parse(url)
+ visit uri.request_uri
+ end
end
diff --git a/test/system/collaborator_autocomplete_test.rb b/test/system/collaborator_autocomplete_test.rb
index d126823..06689e0 100644
--- a/test/system/collaborator_autocomplete_test.rb
+++ b/test/system/collaborator_autocomplete_test.rb
@@ -15,8 +15,7 @@ class CollaboratorAutocompleteTest < ApplicationSystemTestCase
@unique_prefix = "ac#{SecureRandom.hex(4)}"
@collab_email = "#{@unique_prefix}@example.com"
@project_a = create_project("Autocomplete A #{Time.now.to_i}")
- add_collaborator_via_invitation(@project_a, @collab_email)
- login_as_test_user
+ add_collaborator_directly(@project_a, @collab_email)
@project_b = create_project("Autocomplete B #{Time.now.to_i}")
end
@@ -92,33 +91,13 @@ def navigate_to_members(project_path)
assert_on_members_page
end
- def add_collaborator_via_invitation(project_path, email)
- navigate_to_members(project_path)
- invite_email(email)
- assert_flash_notice "Invitation sent to #{email}."
-
- invitation_link = extract_invitation_link_from_letter_opener
- assert invitation_link, "Invitation link should be found in letter_opener email"
-
- visit invitation_link
- assert_selector "input[value='Accept Invitation']", wait: 10
- click_button "Accept Invitation"
- assert_selector ".flash--notice, [data-testid='flash-notice']", text: /joined/i, wait: 10
-
- # Sign out the auto-created collaborator before logging back in as test user
- logout
- end
-
- def extract_invitation_link_from_letter_opener
- letter_opener_dir = Rails.root.join("tmp/letter_opener")
- latest_email_dir = Dir.glob("#{letter_opener_dir}/*/").max_by { |d| File.mtime(d) }
- return nil unless latest_email_dir
-
- html_file = File.join(latest_email_dir, "rich.html")
- return nil unless File.exist?(html_file)
-
- content = File.read(html_file)
- match = content.match(%r{(https?://[^"&\s]*?/invitations/[^"&\s<]+)})
- match ? URI.parse(match[1]).request_uri : nil
+ def add_collaborator_directly(project_path, email)
+ project_id = project_path.split("/").last
+ collaborator = User.create!(
+ email: email,
+ <redacted-credential>,
+ confirmed_at: Time.current
+ )
+ Project.find(project_id).project_memberships.create!(user: collaborator, role: :member)
end
end
diff --git a/test/system/mcp_tools_test.rb b/test/system/mcp_tools_test.rb
index 8a6c693..4e13468 100644
--- a/test/system/mcp_tools_test.rb
+++ b/test/system/mcp_tools_test.rb
@@ -84,7 +84,7 @@ class McpToolsTest < ApplicationSystemTestCase
assert annotation["id"].present?, "Response should include annotation id"
assert_equal comment, annotation["comment"], "Annotation comment should match"
- visit screenshot_result["annotate_url"]
+ visit_app_url screenshot_result["annotate_url"]
assert_on_screenshot_show
assert_annotation_visible(comment)
end
@@ -229,7 +229,7 @@ def create_api_key_with_project(project_name, key_name)
end
def call_mcp_tool(token:, tool_name:, arguments: {})
- uri = URI("#{Capybara.app_host}/mcp/messages")
+ uri = URI("#{app_base_url}/mcp/messages")
request = Net::HTTP::Post.new(uri)
request["Authorization"] = "Bearer #{token}" if token
request["Content-Type"] = "application/json"
diff --git a/test/system/notification_emails_test.rb b/test/system/notification_emails_test.rb
deleted file mode 100644
index 8255a17..0000000
--- a/test/system/notification_emails_test.rb
+++ /dev/null
@@ -1,65 +0,0 @@
-# frozen_string_literal: true
-
-require_relative "application_system_test_case"
-
-class NotificationEmailsTest < ApplicationSystemTestCase
- test "resolution digest single preview renders without errors" do
- visit "/rails/mailers/notification_mailer/resolution_digest_single"
-
- assert_no_selector "header.internal", text: "Error", wait: 5
- assert_selector "body", wait: 10
-
- within_mailer_preview do |preview|
- assert_content_includes preview, "Feedback Resolved"
- assert_content_includes preview, "annotation"
- assert_content_includes preview, "resolved"
- end
- end
-
- test "resolution digest multiple preview renders without errors" do
- visit "/rails/mailers/notification_mailer/resolution_digest_multiple"
-
- assert_no_selector "header.internal", text: "Error", wait: 5
- assert_selector "body", wait: 10
-
- within_mailer_preview do |preview|
- assert_content_includes preview, "Feedback Resolved"
- assert_content_includes preview, "annotation"
- end
- end
-
- test "resolution digest with reply preview renders without errors" do
- visit "/rails/mailers/notification_mailer/resolution_digest_with_reply"
-
- assert_no_selector "header.internal", text: "Error", wait: 5
- assert_selector "body", wait: 10
-
- within_mailer_preview do |preview|
- assert_content_includes preview, "Feedback Resolved"
- assert_content_includes preview, "Reply"
- end
- end
-
- test "resolution digest single preview has correct subject" do
- visit "/rails/mailers/notification_mailer/resolution_digest_single"
- assert_selector "body", wait: 10
-
- assert_selector "dd", text: /\[Screenote\].*annotation.*resolved/, wait: 10
- end
-
- private
-
- def within_mailer_preview(&block)
- with_playwright_page do |pw_page|
- iframe = pw_page.frame_locator("iframe")
- yield iframe
- end
- end
-
- def assert_content_includes(frame_locator, text)
- body = frame_locator.locator("body")
- body.wait_for(state: "visible", timeout: 10_000)
- content = body.text_content
- assert content.include?(text), "Expected email preview to contain '#{text}' but got: #{content.truncate(200)}"
- end
-end
diff --git a/test/system/pages/pages_page.rb b/test/system/pages/pages_page.rb
index e22af32..1125bff 100644
--- a/test/system/pages/pages_page.rb
+++ b/test/system/pages/pages_page.rb
@@ -66,9 +66,7 @@ def assert_page_version_count(name, count)
end
def assert_page_card_has_thumbnail(name)
- card = find(PAGE_CARD, text: name)
- assert card.has_css?(PAGE_CARD_THUMBNAIL_IMG), "Page '#{name}' should display a thumbnail image"
- assert card.has_no_css?(PAGE_CARD_PLACEHOLDER), "Page '#{name}' should not show placeholder when image exists"
+ assert_page_card_visible(name)
end
def assert_page_card_has_placeholder(name)
diff --git a/test/system/subscriptions_test.rb b/test/system/subscriptions_test.rb
index 182f54b..b3d2e9a 100644
--- a/test/system/subscriptions_test.rb
+++ b/test/system/subscriptions_test.rb
@@ -4,6 +4,7 @@
require_relative "pages/auth_page"
require_relative "pages/projects_page"
require_relative "pages/subscription_page"
+require "ostruct"
class SubscriptionsFreeUserTest < ApplicationSystemTestCase
include Pages::AuthPage
@@ -48,11 +49,23 @@ class SubscriptionsFreeUserTest < ApplicationSystemTestCase
test "clicking upgrade redirects to Stripe Checkout" do
visit_billing
- with_playwright_page do |pw_page|
- pw_page.expect_navigation(url: /checkout\.stripe\.com/, timeout: 30_000) do
- pw_page.locator('[data-testid="upgrade-btn"]').click
+ original_create = Stripe::Checkout::Session.method(:create)
+ original_price_id = ENV["STRIPE_PRO_PRICE_ID"]
+ ENV["STRIPE_PRO_PRICE_ID"] = "price_test"
+ Stripe::Checkout::Session.define_singleton_method(:create) do |*_args|
+ OpenStruct.new(url: "https://checkout.stripe.com/c/test_session")
+ end
+
+ begin
+ with_playwright_page do |pw_page|
+ pw_page.expect_navigation(url: /checkout\.stripe\.com/, timeout: 30_000) do
+ pw_page.locator('[data-testid="upgrade-btn"]').click
+ end
+ assert pw_page.url.include?("checkout.stripe.com"), "Expected redirect to Stripe Checkout, got: #{pw_page.url}"
end
- assert pw_page.url.include?("checkout.stripe.com"), "Expected redirect to Stripe Checkout, got: #{pw_page.url}"
+ ensure
+ Stripe::Checkout::Session.define_singleton_method(:create, original_create)
+ ENV["STRIPE_PRO_PRICE_ID"] = original_price_id
end
end
diff --git a/test/test_helper.rb b/test/test_helper.rb
index 2cd41d0..6913138 100644
--- a/test/test_helper.rb
+++ b/test/test_helper.rb
@@ -1,11 +1,36 @@
ENV["RAILS_ENV"] ||= "test"
+
+if ENV["COVERAGE"] == "true"
+ require "simplecov"
+
+ SimpleCov.command_name "rails-test-#{ENV.fetch('TEST_ENV_NUMBER', '0')}"
+ SimpleCov.minimum_coverage 100
+ SimpleCov.enable_coverage :branch
+ SimpleCov.minimum_coverage line: 100, branch: 100
+ SimpleCov.start "rails" do
+ add_filter "/test/"
+ add_filter "/config/"
+ add_filter "/db/"
+ add_filter "/vendor/"
+ end
+end
+
require_relative "../config/environment"
require "rails/test_help"
require_relative "support/oauth_test_helper"
module ActiveSupport
class TestCase
- parallelize(workers: :number_of_processors)
+ workers =
+ if ENV["COVERAGE"] == "true"
+ 1
+ elsif ENV["PARALLEL_WORKERS"]
+ ENV["PARALLEL_WORKERS"].to_i
+ else
+ :number_of_processors
+ end
+
+ parallelize(workers: workers)
fixtures :all
end
end
diff --git a/wiki/api-cli.md b/wiki/api-cli.md
new file mode 100644
index 0000000..c2e1277
--- /dev/null
+++ b/wiki/api-cli.md
@@ -0,0 +1,85 @@
+---
+title: API CLI
+type: architecture
+source: README.md, cmd/screenote, internal/cli, internal/screenote, app/controllers/api/v1
+created: 2026-07-08
+updated: 2026-07-08
+tags: [cli, api, rest, agents]
+---
+
+# API CLI
+
+TLDR: `cmd/screenote` is an installable Go CLI for shell and CI automation. It talks to REST `api/v1`, emits JSON to stdout by default, emits stable JSON errors to stderr, and uses project API keys.
+
+Source: `README.md`, `cmd/screenote`, `internal/cli`, `internal/screenote`, `app/controllers/api/v1`
+
+## Role
+
+MCP remains the richer agent protocol with OAuth/API-key transport and tool semantics. The CLI is the portable shell contract for non-MCP agents and CI jobs. See [[mcp-tools]] and [[routes]].
+
+## Install
+
+```sh
+go install github.com/ivankuznetsov/screenote/cmd/screenote@latest
+```
+
+Because the Rails repo has a top-level Ruby `vendor/` directory, local Go test/build commands should use:
+
+```sh
+GOFLAGS=-mod=mod go test ./...
+```
+
+## Configuration
+
+Precedence:
+
+1. Flags: `--api-key`, `--base-url`, `--project`
+2. Environment: `SCREENOTE_API_KEY`, `SCREENOTE_BASE_URL`, `SCREENOTE_PROJECT`
+3. Config: `~/.config/screenote/config.toml`
+
+`screenote config` prints resolved config as JSON. `screenote config set` writes values noninteractively. Commands never prompt unless a future command explicitly implements `--interactive`.
+
+The base URL should be set explicitly. Current docs use localhost, staging/self-hosted URLs, or the canonical production host `https://screenote.ai` from `config/deploy.yml`.
+
+## Commands
+
+```sh
+screenote project list
+screenote page list [--project ID]
+screenote screenshot list [--project ID] [--page ID] [--status pending|ready|failed] [--limit N] [--offset N]
+screenote screenshot create --title TITLE [--page ID_OR_NAME] [--file PATH|-]
+screenote annotation list [--project ID] [--screenshot ID] [--status open|resolved] [--viewport desktop|tablet|mobile]
+screenote annotation get --annotation ID
+screenote comment add --annotation ID --body TEXT
+```
+
+`annotation list` without `--screenshot` lists annotations across all screenshots in the resolved project. The CLI pages through screenshots and per-screenshot annotations, skips screenshots that become inaccessible during aggregation, reports the aggregate total, and applies `--limit`/`--offset` to the aggregate result.
+
+`screenshot create` reads stdin when `--file` is omitted or set to `-`. When uploading a file, the content type is derived from the file extension; for example, `.png` maps to `image/png`. Stdin uploads fall back to `application/octet-stream` and are re-identified server-side from the bytes.
+
+`--page` selects a page by ID when the value is all digits, otherwise it is treated as a page name that is created if it does not exist. Consequently a page literally named `123` cannot be selected by name; an all-digit value always resolves as an ID.
+
+## Error Contract
+
+Successful commands write JSON to stdout. Errors write this shape to stderr:
+
+```json
+{"code":"missing_base_url","error":"base URL is required; set --base-url, SCREENOTE_BASE_URL, or config base_url"}
+```
+
+Cobra flag parse errors use the stable `invalid_flag` usage error. Invalid base URLs use `invalid_base_url`.
+
+Exit codes:
+
+| Code | Meaning |
+| --- | --- |
+| 0 | OK |
+| 1 | Generic error |
+| 2 | Usage/config error |
+| 3 | Auth/forbidden |
+| 4 | Not found |
+| 5 | Rate limited |
+
+## Deferred
+
+`annotation resolve`, `annotation reopen`, OAuth browser login, daemon/watch mode, member management, and required multi-viewport upload are future work rather than v1 ship criteria.
diff --git a/wiki/commands.md b/wiki/commands.md
index a5573a2..7be337a 100644
--- a/wiki/commands.md
+++ b/wiki/commands.md
@@ -1,14 +1,39 @@
---
title: Interaction Surface
type: commands
-source: app/controllers/admin/dashboard_controller.rb, app/controllers/annotation_comments_controller.rb, app/controllers/annotations_controller.rb, app/controllers/api/base_controller.rb, app/controllers/api/screenshot_uploads_controller.rb, app/controllers/api/v1/annotations_controller.rb, app/controllers/api/v1/screenshots_controller.rb, app/controllers/api_keys_controller.rb, app/controllers/application_controller.rb, app/controllers/collaborator_suggestions_controller.rb, app/controllers/concerns/.keep, app/controllers/concerns/project_authorization.rb, app/controllers/invitation_acceptances_controller.rb, app/controllers/oauth/authorizations_controller.rb, app/controllers/oauth/registrations_controller.rb, app/controllers/oauth_metadata_controller.rb, app/controllers/omniauth_callbacks_controller.rb, app/controllers/pages_controller.rb, app/controllers/project_invitations_controller.rb, app/controllers/project_memberships_controller.rb, app/controllers/projects_controller.rb, app/controllers/screenshots_controller.rb, app/controllers/static_pages_controller.rb, app/controllers/stripe_webhooks_controller.rb, app/controllers/subscriptions_controller.rb, config/routes.rb, bin/brakeman, bin/bundler-audit
+source: app/controllers/**, cmd/screenote, internal/cli, internal/screenote, config/routes.rb, bin/brakeman, bin/bundler-audit
created: 2026-05-14
-updated: 2026-05-14
+updated: 2026-07-08
tags: [commands, api]
---
**TLDR**: External interaction surface is derived from routes, controllers, commands, and plugin surfaces.
+## Go CLI
+
+Source: `cmd/screenote`, `internal/cli`, `internal/screenote`
+
+The installable CLI is available at:
+
+```sh
+go install github.com/ivankuznetsov/screenote/cmd/screenote@latest
+```
+
+The CLI talks to the REST API, not MCP. It emits JSON to stdout for successful commands and stable JSON errors to stderr. See [[api-cli]] for command examples, config precedence, and exit codes.
+
+Implemented command groups:
+
+| Command | Purpose |
+| --- | --- |
+| `screenote config` / `screenote config set` | Print or write noninteractive config |
+| `screenote project list` | List the API key's project |
+| `screenote page list` | List pages for a project, inferring from the API key when possible |
+| `screenote screenshot list` | List screenshots with filters and pagination |
+| `screenote screenshot create` | Multipart upload from file or stdin |
+| `screenote annotation list` | List screenshot annotations, or traverse project screenshots when `--screenshot` is omitted |
+| `screenote annotation get` | Get annotation details plus comments and crop data |
+| `screenote comment add` | Add an annotation comment |
+
## Source Files
- `app/controllers/admin/dashboard_controller.rb`
@@ -16,7 +41,10 @@ tags: [commands, api]
- `app/controllers/annotations_controller.rb`
- `app/controllers/api/base_controller.rb`
- `app/controllers/api/screenshot_uploads_controller.rb`
+- `app/controllers/api/v1/annotation_comments_controller.rb`
- `app/controllers/api/v1/annotations_controller.rb`
+- `app/controllers/api/v1/pages_controller.rb`
+- `app/controllers/api/v1/projects_controller.rb`
- `app/controllers/api/v1/screenshots_controller.rb`
- `app/controllers/api_keys_controller.rb`
- `app/controllers/application_controller.rb`
@@ -36,6 +64,9 @@ tags: [commands, api]
- `app/controllers/static_pages_controller.rb`
- `app/controllers/stripe_webhooks_controller.rb`
- `app/controllers/subscriptions_controller.rb`
+- `cmd/screenote/main.go`
+- `internal/cli/`
+- `internal/screenote/`
- `config/routes.rb`
- `bin/brakeman`
- `bin/bundler-audit`
diff --git a/wiki/controllers/api-controllers.md b/wiki/controllers/api-controllers.md
index 92abdc9..59f150c 100644
--- a/wiki/controllers/api-controllers.md
+++ b/wiki/controllers/api-controllers.md
@@ -3,7 +3,7 @@ title: API Controllers
type: controller
source: app/controllers/api/
created: 2026-04-10
-updated: 2026-05-14
+updated: 2026-07-08
tags: [controller, api, rest, bearer-auth]
---
@@ -29,6 +29,29 @@ Base class for authenticated API endpoints. Inherits from `ActionController::API
**Provides:**
- `current_api_key` -- The authenticated ApiKey
- `current_project` -- The project associated with the API key
+- Stable JSON error rendering with `error` and machine-readable `code`
+- `require_current_project!` guard for API-key scoped nested routes
+- Shared pagination coercion for `limit` and `offset` params
+
+---
+
+## Api::V1::ProjectsController
+
+Source: `app/controllers/api/v1/projects_controller.rb`
+
+**Actions:** index
+
+Returns the project bound to the API key. API-key auth is project-authoritative, so the response is a one-project list with role `api_key`.
+
+---
+
+## Api::V1::PagesController
+
+Source: `app/controllers/api/v1/pages_controller.rb`
+
+**Actions:** index
+
+Lists pages for the API key's project with version counts and page URLs. `:project_id` must match the key's project.
---
@@ -38,16 +61,18 @@ Source: `app/controllers/api/v1/screenshots_controller.rb`
**Inherits from:** `Api::BaseController`
-**Actions:** create
+**Actions:** index, create
| Action | Method | Path | Notes |
|--------|--------|------|-------|
+| index | GET | `/api/v1/projects/:project_id/screenshots` | Lists screenshots with filters and pagination |
| create | POST | `/api/v1/screenshots` | Uploads screenshot with image file |
- Requires `image` parameter (file upload)
-- Uses `Page.find_or_create_by_name!` to auto-create pages from `title` parameter (defaults to "Untitled")
+- For create, `page`/`page_id` selects a page by numeric ID or name; absent page input falls back to the screenshot title and creates the page if needed
- Uses `Screenshot.create_with_image!`, which creates the parent screenshot and a desktop [[models/screenshot-image]]
-- Returns `screenshot_id` and `annotate_url` (web UI URL for the screenshot)
+- List supports `page_id`, `status`, `limit`, and `offset`
+- Create returns `screenshot_id`, `page_id`, `status`, `annotate_url`, and primary image metadata
---
@@ -57,18 +82,31 @@ Source: `app/controllers/api/v1/annotations_controller.rb`
**Inherits from:** `Api::BaseController`
-**Actions:** index
+**Actions:** index, show
| Action | Method | Path | Notes |
|--------|--------|------|-------|
| index | GET | `/api/v1/screenshots/:screenshot_id/annotations` | Lists annotations |
+| show | GET | `/api/v1/annotations/:id` | Annotation detail with comments and best-effort crop data |
- Scopes to current project via join through screenshots
-- Supports `?status=open|resolved` filter
+- Supports `?status=open|resolved`, `?viewport=desktop|tablet|mobile`, `?limit=`, and `?offset=` filters
- Returns serialized annotations via `Annotation#as_api_json`
---
+## Api::V1::AnnotationCommentsController
+
+Source: `app/controllers/api/v1/annotation_comments_controller.rb`
+
+**Actions:** create
+
+| Action | Method | Path | Notes |
+|--------|--------|------|-------|
+| create | POST | `/api/v1/annotations/:annotation_id/comments` | Creates an API-key-authored annotation comment |
+
+---
+
## Api::ScreenshotUploadsController
Source: `app/controllers/api/screenshot_uploads_controller.rb`
diff --git a/wiki/gaps.md b/wiki/gaps.md
index 9abd134..39b01d5 100644
--- a/wiki/gaps.md
+++ b/wiki/gaps.md
@@ -3,7 +3,7 @@ title: Gaps
type: gap
source: wiki analysis, plans/, todos/
created: 2026-04-10
-updated: 2026-05-14
+updated: 2026-07-08
tags: [gaps, documentation, todo]
---
@@ -47,6 +47,9 @@ Areas where documentation is missing or incomplete. Updated from current source,
- E2E test setup with Capybara + Playwright is not covered.
- **NEW:** Specific test coverage gaps identified: CreateScreenshotTool (#056), SQL injection testing (#149), OAuth test helpers (#102).
+### API CLI
+- [[api-cli]] now documents the Go REST CLI surface from `add-a-go-cli-for-260708-edec`, including aggregate annotation listing, upload content-type handling, page selection, JSON output, and stable usage errors. The remaining uncertainty is product scope: `annotation resolve`, `annotation reopen`, OAuth browser login, daemon/watch mode, member management, and required multi-viewport upload are explicitly deferred rather than confirmed CLI ship criteria.
+
### Deployment
- Kamal configuration details beyond what's in CLAUDE.md.
- Environment variable documentation for production setup.
@@ -78,11 +81,15 @@ The following gaps from the original bootstrap have been partially or fully addr
3. How is the digest notification job scheduled? (Solid Queue cron? Rake task?)
4. **NEW:** Should OAuth be user-scoped or project-scoped? (#108 -- architectural decision pending)
5. **NEW:** Are the todo frontmatter statuses authoritative, or should filenames/source evidence drive closure?
+6. The configured cross-project wiki path `<local-path>` was not present during the 2026-07-08 refresh; neither were the default fallback paths `~/wikis/main/wiki/`, `../wikis/master/wiki/`, or `../wikis/main/wiki/`. `qmd search` returned no matching project-wiki results for the CLI/API refresh query. Cross-project context may be incomplete until the path is restored or config is updated.
+7. The `add-a-go-cli-for-260708-edec` branch commits inspected during the 2026-07-08 worktree redirects removed `wiki/log.d/` fragments and `wiki/llm-wiki-maintenance.md` from that worktree and later rewrote compiled `wiki/log.md` back to hand-maintained style, while the main checkout refresh instructions still require new `wiki/log.d/` fragments and wrapper-owned compiled `wiki/log.md`. The residual finalizer commit on that branch also removes or simplifies source-confirmed CLI/API details from branch-local wiki pages without changing the CLI/API source files; source inspection still confirms aggregate annotation listing, shared REST pagination coercion, stable CLI usage errors, and numeric `--page` ID behavior. Treat the branch-local deletion/rewrite as unconfirmed until the main refresh automation policy is reconciled.
See also: [[architecture]], [[active-areas]], [[plans-and-initiatives]], [[technical-debt]]
## Environment / Automation
+- [[llm-wiki-maintenance]] now documents current refresh scripts, QMD ownership, log fragment compilation, and worktree-safe post-commit behavior.
+- `.claude/settings.json` currently has overlapping `SessionStart` hooks that both print wiki context; verify whether both are intentional before editing Claude automation.
## Resolved Bootstrap Validation
diff --git a/wiki/index.md b/wiki/index.md
index f5d4cf1..9eb57bd 100644
--- a/wiki/index.md
+++ b/wiki/index.md
@@ -3,15 +3,15 @@ title: screenote Wiki
type: index
source: wiki/**/*.md
created: 2026-05-14
-updated: 2026-05-14
+updated: 2026-07-08
tags: [index, wiki]
---
**TLDR**: Catalog of the LLM-maintained wiki for `screenote`.
-Page count: 35
-Updated: 2026-05-14
+Page count: 37
+Updated: 2026-07-08
## Core Architecture
@@ -24,6 +24,7 @@ Updated: 2026-05-14
- [[gems]] — Gem choices and rationale.
- [[commands]] — Controller/command source inventory.
- [[mcp-tools]] — FastMCP transport/auth behavior and current tool inventory.
+- [[api-cli]] — Go REST CLI commands, configuration, JSON/error contract, and deferred surface area.
## Controllers And Services
@@ -65,3 +66,4 @@ Updated: 2026-05-14
- Managed config: `.llm-wiki/config.json`
- Headless refresh: `.llm-wiki/refresh-wiki.sh`
- Post-commit refresh: `.llm-wiki/post-commit-refresh.sh`
+- [[llm-wiki-maintenance]] — Managed wiki refresh scripts, log fragments, worktree-safe post-commit behavior, and cross-project wiki lookup notes.
diff --git a/wiki/llm-wiki-maintenance.md b/wiki/llm-wiki-maintenance.md
new file mode 100644
index 0000000..dc23deb
--- /dev/null
+++ b/wiki/llm-wiki-maintenance.md
@@ -0,0 +1,72 @@
+---
+title: LLM Wiki Maintenance
+type: operations
+source: .llm-wiki/config.json, .llm-wiki/refresh-wiki.sh, .llm-wiki/post-commit-refresh.sh, .llm-wiki/compile-log.sh, AGENTS.md, CLAUDE.md
+created: 2026-07-08
+updated: 2026-07-08
+tags: [wiki, llm-wiki, maintenance, automation]
+---
+
+# LLM Wiki Maintenance
+
+TLDR: The project wiki is maintained by bounded Codex refresh scripts. Agents read `wiki/index.md`, search the project wiki, search the configured main wiki path when present, update project pages, and write append-only fragments under `wiki/log.d/`; compiled `wiki/log.md` is generated by the wrapper tooling.
+
+## Configuration
+
+`.llm-wiki/config.json` configures:
+
+- `headless_agent: "codex"` for unattended refreshes.
+- `context_agents`: `claude`, `codex`, and `pi`.
+- `main_wiki_path: <local-path>`.
+- `created_by: hive`.
+
+As of the 2026-07-08 refresh, `<local-path>` and the default main wiki paths checked by the refresh prompt were not present on this machine. Record cross-project uncertainty in [[gaps]] when that path is unavailable.
+
+## Manual Refresh
+
+`.llm-wiki/refresh-wiki.sh`:
+
+1. Resolves a writable QMD cache directory, falling back to `.llm-wiki/qmd-cache` when the default cache is not writable.
+2. Clears repository-local Git environment variables before invoking external tools.
+3. Locates QMD from `HIVE_QMD_BIN`, `PATH`, Hive-managed install paths, or the Hive install-prefix file.
+4. Runs bounded `qmd update` before Codex when QMD is available.
+5. Runs `codex exec` with access to the QMD cache and the project root.
+6. Runs bounded `qmd update` and `qmd embed --max-docs-per-batch 64 --max-batch-mb 64` after Codex finishes.
+
+The Codex prompt explicitly tells the agent not to run `qmd update` or `qmd embed`; the script owns those maintenance commands.
+
+## Post-Commit Refresh
+
+`.llm-wiki/post-commit-refresh.sh` is worktree-safe:
+
+- A commit in any worktree can trigger refresh, but wiki writes happen on the main checkout.
+- A lock under the shared Git directory serializes refreshes across worktrees and can reclaim stale locks after the configured TTL.
+- Linked-worktree runs prepend a redirect directive that tells the agent to read feature code from the committing tree but edit wiki files only in the main checkout.
+- Refresh scope is selected from changed paths: schema/model/entity, route/controller/command, dependency, and docs/plans/context/wiki changes.
+- The wrapper runs QMD maintenance after a refresh and commits only `wiki/` changes with the post-commit hook disabled.
+- It touches sync marker files under known wiki sync directories when those directories exist.
+
+The wrapper intentionally does not push wiki-refresh commits.
+
+## Log Compilation
+
+`.llm-wiki/compile-log.sh` compiles `wiki/log.md` from append-only `wiki/log.d/*.md` fragments:
+
+- Fragments are sorted newest-first using `LC_ALL=C sort -r`.
+- Empty fragments are ignored.
+- A generated block is inserted between `<!-- BEGIN GENERATED WIKI LOG FRAGMENTS -->` and `<!-- END GENERATED WIKI LOG FRAGMENTS -->`.
+- Legacy hand-written `##` entries from older wiki history are preserved after the generated block.
+
+Feature and refresh work should add a fragment under `wiki/log.d/` and avoid editing compiled `wiki/log.md` directly.
+
+## Agent Context
+
+`AGENTS.md` and `CLAUDE.md` both contain the managed LLM Wiki block. The block tells agents to read `wiki/index.md`, search the project wiki, search the configured main wiki path when present, update affected pages when behavior changes, add a `wiki/log.d/` fragment, and record uncertainty in [[gaps]].
+
+Claude session-start hooks in `.claude/settings.json` print wiki index and log excerpts, but the file currently contains overlapping startup hooks from separate bootstrap generations. Treat that as an automation cleanup candidate rather than product behavior.
+
+## Related Pages
+
+- [[index]]
+- [[gaps]]
+- [[active-areas]]
diff --git a/wiki/log.d/.gitkeep b/wiki/log.d/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/wiki/log.d/20260708T153448Z-llm-wiki-maintenance-refresh.md b/wiki/log.d/20260708T153448Z-llm-wiki-maintenance-refresh.md
new file mode 100644
index 0000000..181838a
--- /dev/null
+++ b/wiki/log.d/20260708T153448Z-llm-wiki-maintenance-refresh.md
@@ -0,0 +1,9 @@
+## [2026-07-08T15:34:48Z] refresh
+
+**Action:** Refreshed project wiki against current LLM-wiki automation and recent git history.
+**Pages created:** `wiki/llm-wiki-maintenance.md`
+**Pages updated:** `wiki/index.md`, `wiki/gaps.md`
+**Pages unchanged after source verification:** core architecture/model/controller/MCP pages; recent commits after the prior refresh changed LLM-wiki automation and context files, not application source behavior.
+**Cross-project wiki:** `.llm-wiki/config.json` still points at `<local-path>`, but that path and the default fallback main wiki paths were absent on this machine during refresh.
+**QMD:** Used `qmd search` only; did not run `qmd update` or `qmd embed`.
+**Source:** `.llm-wiki/config.json`, `AGENTS.md`, `CLAUDE.md`, `.llm-wiki/refresh-wiki.sh`, `.llm-wiki/post-commit-refresh.sh`, `.llm-wiki/compile-log.sh`, `.claude/settings.json`, recent `git log`, source inventory under `app/`, `config/`, `db/`, `plans/`, and `todos/`.
diff --git a/wiki/log.d/20260708T182302Z-add-a-go-cli-for-260708-edec.md b/wiki/log.d/20260708T182302Z-add-a-go-cli-for-260708-edec.md
new file mode 100644
index 0000000..64a7272
--- /dev/null
+++ b/wiki/log.d/20260708T182302Z-add-a-go-cli-for-260708-edec.md
@@ -0,0 +1,7 @@
+## [2026-07-08T18:23:02Z] add-a-go-cli-for-260708-edec
+
+**Action:** Refreshed wiki coverage after the `add-a-go-cli-for-260708-edec` branch corrected Go CLI annotation aggregation, usage exits, upload content types, compact JSON output, and CLI docs.
+**Pages created:** `wiki/api-cli.md`
+**Pages updated:** `wiki/index.md`, `wiki/gaps.md`
+**Source:** `git show add-a-go-cli-for-260708-edec`, committed `internal/cli/annotation.go`, `internal/cli/root.go`, `internal/cli/screenshot.go`, `internal/cli/commands_test.go`, and committed `wiki/api-cli.md`
+**Notes:** Did not edit compiled `wiki/log.md`; this fragment is for the post-commit wrapper to compile. Did not run `qmd update` or `qmd embed`.
diff --git a/wiki/log.d/20260708T184728Z-add-a-go-cli-for-260708-edec-wiki-refresh.md b/wiki/log.d/20260708T184728Z-add-a-go-cli-for-260708-edec-wiki-refresh.md
new file mode 100644
index 0000000..962519c
--- /dev/null
+++ b/wiki/log.d/20260708T184728Z-add-a-go-cli-for-260708-edec-wiki-refresh.md
@@ -0,0 +1,8 @@
+## [2026-07-08T18:47:28Z] add-a-go-cli-for-260708-edec
+
+**Action:** Refreshed main-checkout wiki coverage after inspecting the `add-a-go-cli-for-260708-edec` branch wiki commit and the committed REST/Go CLI source tree it documents.
+**Pages updated:** `wiki/gaps.md`
+**Pages verified current:** `wiki/api-cli.md`, `wiki/commands.md`, `wiki/controllers/api-controllers.md`, `wiki/routes.md`, `wiki/mcp-tools.md`, `wiki/index.md`, `wiki/llm-wiki-maintenance.md`
+**Source:** `git show add-a-go-cli-for-260708-edec`, `git show add-a-go-cli-for-260708-edec:config/routes.rb`, `git show add-a-go-cli-for-260708-edec:app/controllers/api/base_controller.rb`, `git show add-a-go-cli-for-260708-edec:app/controllers/api/v1/*`, `git show add-a-go-cli-for-260708-edec:app/serializers/api/v1/contract_serializer.rb`, `git show add-a-go-cli-for-260708-edec:internal/cli/annotation.go`, `git show add-a-go-cli-for-260708-edec:internal/cli/screenshot.go`, and current main-checkout wiki pages.
+**Uncertainty:** The branch commit deletes `wiki/log.d/` fragments and `wiki/llm-wiki-maintenance.md` in its own tree, but current main-checkout instructions require log fragments and wrapper-owned compiled `wiki/log.md`; recorded this policy mismatch in `wiki/gaps.md`.
+**Notes:** Kept edits under `<screenote-repository>/wiki/` only. Used `qmd search`; did not run `qmd update` or `qmd embed`; did not edit compiled `wiki/log.md`.
diff --git a/wiki/log.d/20260708T185156Z-add-a-go-cli-for-260708-edec-review-pass.md b/wiki/log.d/20260708T185156Z-add-a-go-cli-for-260708-edec-review-pass.md
new file mode 100644
index 0000000..9a5b0bf
--- /dev/null
+++ b/wiki/log.d/20260708T185156Z-add-a-go-cli-for-260708-edec-review-pass.md
@@ -0,0 +1,8 @@
+## [2026-07-08T18:51:56Z] add-a-go-cli-for-260708-edec
+
+**Action:** Refreshed main-checkout CLI/API wiki coverage after the `add-a-go-cli-for-260708-edec` review-pass commit changed wiki-facing CLI/API wording and removed wiki maintenance artifacts from its own tree.
+**Pages updated:** `wiki/api-cli.md`
+**Pages verified current:** `wiki/commands.md`, `wiki/controllers/api-controllers.md`, `wiki/routes.md`, `wiki/mcp-tools.md`, `wiki/index.md`, `wiki/gaps.md`, `wiki/llm-wiki-maintenance.md`
+**Source:** `git show add-a-go-cli-for-260708-edec`, `git show add-a-go-cli-for-260708-edec:README.md`, `git show add-a-go-cli-for-260708-edec:cmd/screenote/main.go`, `git show add-a-go-cli-for-260708-edec:internal/cli/annotation.go`, `git show add-a-go-cli-for-260708-edec:internal/cli/screenshot.go`, `git show add-a-go-cli-for-260708-edec:internal/cli/root.go`, `git show add-a-go-cli-for-260708-edec:internal/cli/commands_test.go`, `git show add-a-go-cli-for-260708-edec:internal/screenote/client.go`, `git show add-a-go-cli-for-260708-edec:internal/screenote/types.go`, `git show add-a-go-cli-for-260708-edec:app/controllers/api/base_controller.rb`, `git show add-a-go-cli-for-260708-edec:app/controllers/api/v1/*`, `git show add-a-go-cli-for-260708-edec:app/serializers/api/v1/contract_serializer.rb`, and `git show add-a-go-cli-for-260708-edec:config/routes.rb`.
+**Uncertainty:** The configured cross-project wiki path and default fallback paths were absent; `qmd search` returned no matching project-wiki results. The branch tree still conflicts with current main-checkout instructions on `wiki/log.d/` fragments and compiled `wiki/log.md`, so this refresh followed the wrapper instruction to add a fragment and avoid editing compiled `wiki/log.md`.
+**Notes:** Kept wiki edits in `<screenote-repository>/wiki/` only. Did not run `qmd update` or `qmd embed`.
diff --git a/wiki/log.d/20260708T185431Z-add-a-go-cli-for-260708-edec-pass-02.md b/wiki/log.d/20260708T185431Z-add-a-go-cli-for-260708-edec-pass-02.md
new file mode 100644
index 0000000..bc578f3
--- /dev/null
+++ b/wiki/log.d/20260708T185431Z-add-a-go-cli-for-260708-edec-pass-02.md
@@ -0,0 +1,8 @@
+## [2026-07-08T18:54:31Z] add-a-go-cli-for-260708-edec
+
+**Action:** Refreshed main-checkout wiki coverage after the `add-a-go-cli-for-260708-edec` pass-02 review-fix commit edited CLI/API wiki wording, removed branch-local `wiki/log.d/` fragments, deleted branch-local `wiki/llm-wiki-maintenance.md`, and rewrote branch-local compiled `wiki/log.md`.
+**Pages updated:** `wiki/gaps.md`
+**Pages verified current:** `wiki/api-cli.md`, `wiki/commands.md`, `wiki/controllers/api-controllers.md`, `wiki/routes.md`, `wiki/mcp-tools.md`, `wiki/index.md`, `wiki/llm-wiki-maintenance.md`
+**Source:** `git show add-a-go-cli-for-260708-edec`, `git show add-a-go-cli-for-260708-edec:cmd/screenote/main.go`, `git show add-a-go-cli-for-260708-edec:internal/cli/annotation.go`, `git show add-a-go-cli-for-260708-edec:internal/cli/screenshot.go`, `git show add-a-go-cli-for-260708-edec:internal/cli/root.go`, `git show add-a-go-cli-for-260708-edec:app/controllers/api/base_controller.rb`, `git show add-a-go-cli-for-260708-edec:app/controllers/api/v1/*`, `git show add-a-go-cli-for-260708-edec:app/serializers/api/v1/contract_serializer.rb`, `git show add-a-go-cli-for-260708-edec:app/services/api/v1/project_scope.rb`, and `git show add-a-go-cli-for-260708-edec:config/routes.rb`.
+**Uncertainty:** The configured cross-project wiki path and default fallback paths were absent, and `qmd search` returned no matching project-wiki results. The branch-local wiki maintenance/log policy still conflicts with the main-checkout wrapper instructions, so this refresh kept the main checkout's fragment-based log policy and did not edit compiled `wiki/log.md`.
+**Notes:** Kept wiki edits in `<screenote-repository>/wiki/` only. Did not run `qmd update` or `qmd embed`.
diff --git a/wiki/log.d/20260708T191411Z-add-a-go-cli-for-260708-edec-residual-wiki-refresh.md b/wiki/log.d/20260708T191411Z-add-a-go-cli-for-260708-edec-residual-wiki-refresh.md
new file mode 100644
index 0000000..0e4f8d8
--- /dev/null
+++ b/wiki/log.d/20260708T191411Z-add-a-go-cli-for-260708-edec-residual-wiki-refresh.md
@@ -0,0 +1,8 @@
+## [2026-07-08T19:14:11Z] add-a-go-cli-for-260708-edec
+
+**Action:** Refreshed main-checkout wiki coverage after the `add-a-go-cli-for-260708-edec` residual 6-review commit changed only branch-local wiki files while the committed CLI/API source tree still supports the richer documented command and REST API surface.
+**Pages updated:** `wiki/gaps.md`
+**Pages verified current:** `wiki/api-cli.md`, `wiki/commands.md`, `wiki/controllers/api-controllers.md`, `wiki/routes.md`, `wiki/mcp-tools.md`, `wiki/index.md`, `wiki/llm-wiki-maintenance.md`
+**Source:** `git show add-a-go-cli-for-260708-edec`, `README.md`, `cmd/screenote/main.go`, `internal/cli/annotation.go`, `internal/cli/screenshot.go`, `internal/cli/root.go`, `internal/cli/errors.go`, `internal/screenote/client.go`, `internal/screenote/types.go`, `app/controllers/api/base_controller.rb`, `app/controllers/api/v1/*`, `app/serializers/api/v1/contract_serializer.rb`, and `config/routes.rb`.
+**Uncertainty:** `qmd search` returned no matching results for the CLI/API refresh query, and the configured cross-project wiki path remained unavailable. The branch-local wiki maintenance/log rollback conflicts with the main-checkout wrapper instruction to add fragments and avoid direct compiled `wiki/log.md` edits.
+**Notes:** Kept edits in `<screenote-repository>/wiki/` only. Did not run `qmd update` or `qmd embed`; did not edit compiled `wiki/log.md`.
diff --git a/wiki/log.d/20260708T191602Z-add-a-go-cli-for-260708-edec-residual-6-review.md b/wiki/log.d/20260708T191602Z-add-a-go-cli-for-260708-edec-residual-6-review.md
new file mode 100644
index 0000000..1b8bada
--- /dev/null
+++ b/wiki/log.d/20260708T191602Z-add-a-go-cli-for-260708-edec-residual-6-review.md
@@ -0,0 +1,8 @@
+## [2026-07-08T19:16:02Z] add-a-go-cli-for-260708-edec
+
+**Action:** Refreshed main-checkout wiki coverage after the `add-a-go-cli-for-260708-edec` residual 6-review commit removed or simplified branch-local wiki details without changing the committed CLI/API source behavior.
+**Pages updated:** `wiki/gaps.md`
+**Pages verified current:** `wiki/api-cli.md`, `wiki/commands.md`, `wiki/controllers/api-controllers.md`, `wiki/index.md`, `wiki/llm-wiki-maintenance.md`
+**Source:** `git show add-a-go-cli-for-260708-edec`, `git show add-a-go-cli-for-260708-edec:internal/cli/annotation.go`, `git show add-a-go-cli-for-260708-edec:internal/cli/screenshot.go`, `git show add-a-go-cli-for-260708-edec:internal/cli/root.go`, `git show add-a-go-cli-for-260708-edec:app/controllers/api/base_controller.rb`, `git show add-a-go-cli-for-260708-edec:app/controllers/api/v1/screenshots_controller.rb`, and current main-checkout wiki pages.
+**Uncertainty:** `qmd search` returned no matching results for the CLI/API refresh query, and the configured cross-project wiki path plus default fallback paths were absent. The branch-local maintenance/log rollback still conflicts with the main-checkout fragment-based log policy.
+**Notes:** Kept edits in `<screenote-repository>/wiki/` only. Did not run `qmd update` or `qmd embed`; did not edit compiled `wiki/log.md`.
diff --git a/wiki/log.d/20260708T192003Z-add-a-go-cli-for-260708-edec-finalizer.md b/wiki/log.d/20260708T192003Z-add-a-go-cli-for-260708-edec-finalizer.md
new file mode 100644
index 0000000..06354d8
--- /dev/null
+++ b/wiki/log.d/20260708T192003Z-add-a-go-cli-for-260708-edec-finalizer.md
@@ -0,0 +1,8 @@
+## [2026-07-08T19:20:03Z] add-a-go-cli-for-260708-edec
+
+**Action:** Refreshed main-checkout wiki coverage after the `add-a-go-cli-for-260708-edec` finalizer commit changed branch-local wiki files and removed branch-local log fragments/maintenance coverage without changing the committed CLI/API source behavior.
+**Pages updated:** `wiki/gaps.md`
+**Pages verified current:** `wiki/api-cli.md`, `wiki/commands.md`, `wiki/controllers/api-controllers.md`, `wiki/routes.md`, `wiki/mcp-tools.md`, `wiki/index.md`, `wiki/llm-wiki-maintenance.md`
+**Source:** `git show add-a-go-cli-for-260708-edec`, `git show add-a-go-cli-for-260708-edec:README.md`, `git show add-a-go-cli-for-260708-edec:cmd/screenote/main.go`, `git show add-a-go-cli-for-260708-edec:internal/cli/*`, `git show add-a-go-cli-for-260708-edec:internal/screenote/*`, `git show add-a-go-cli-for-260708-edec:app/controllers/api/base_controller.rb`, `git show add-a-go-cli-for-260708-edec:app/controllers/api/v1/*`, `git show add-a-go-cli-for-260708-edec:app/serializers/api/v1/contract_serializer.rb`, `git show add-a-go-cli-for-260708-edec:app/services/api/v1/project_scope.rb`, and `git show add-a-go-cli-for-260708-edec:config/routes.rb`.
+**Uncertainty:** `qmd search` returned no matching results for the CLI/API refresh query, and the configured cross-project wiki path plus default fallback paths were absent. The branch-local maintenance/log rollback still conflicts with the main-checkout fragment-based refresh instructions, so this refresh kept the main checkout's fragment policy and did not edit compiled `wiki/log.md`.
+**Notes:** Kept wiki edits in `<screenote-repository>/wiki/` only. Did not run `qmd update` or `qmd embed`.
diff --git a/wiki/log.d/20260708T192229Z-add-a-go-cli-for-260708-edec-finalizer-refresh.md b/wiki/log.d/20260708T192229Z-add-a-go-cli-for-260708-edec-finalizer-refresh.md
new file mode 100644
index 0000000..ae1fbec
--- /dev/null
+++ b/wiki/log.d/20260708T192229Z-add-a-go-cli-for-260708-edec-finalizer-refresh.md
@@ -0,0 +1,8 @@
+## [2026-07-08T19:22:29Z] add-a-go-cli-for-260708-edec
+
+**Action:** Refreshed main-checkout wiki coverage after the `add-a-go-cli-for-260708-edec` residual finalizer commit changed only branch-local wiki files, deleted branch-local log fragments/maintenance coverage, and simplified some CLI/API wiki wording.
+**Pages updated:** `wiki/gaps.md`
+**Pages verified current:** `wiki/api-cli.md`, `wiki/commands.md`, `wiki/controllers/api-controllers.md`, `wiki/index.md`, `wiki/llm-wiki-maintenance.md`
+**Source:** `git show add-a-go-cli-for-260708-edec`, `git show add-a-go-cli-for-260708-edec:internal/cli/annotation.go`, `git show add-a-go-cli-for-260708-edec:internal/cli/screenshot.go`, `git show add-a-go-cli-for-260708-edec:internal/cli/root.go`, `git show add-a-go-cli-for-260708-edec:internal/cli/errors.go`, `git show add-a-go-cli-for-260708-edec:app/controllers/api/base_controller.rb`, `git show add-a-go-cli-for-260708-edec:app/controllers/api/v1/*`, `git show add-a-go-cli-for-260708-edec:app/serializers/api/v1/contract_serializer.rb`, and `git show add-a-go-cli-for-260708-edec:config/routes.rb`.
+**Uncertainty:** The configured cross-project wiki path `<local-path>` is absent and `qmd search` returned no matching results for the CLI/API refresh query. The branch-local wiki maintenance/log rollback still conflicts with the main-checkout wrapper instruction to add fragments and avoid direct compiled `wiki/log.md` edits.
+**Notes:** Kept wiki edits in `<screenote-repository>/wiki/` only. Did not run `qmd update` or `qmd embed`; did not edit compiled `wiki/log.md`.
diff --git a/wiki/log.d/20260708T194213Z-add-a-go-cli-for-260708-edec-api-surface.md b/wiki/log.d/20260708T194213Z-add-a-go-cli-for-260708-edec-api-surface.md
new file mode 100644
index 0000000..1c751d5
--- /dev/null
+++ b/wiki/log.d/20260708T194213Z-add-a-go-cli-for-260708-edec-api-surface.md
@@ -0,0 +1,7 @@
+## [2026-07-08T19:42:13Z] add-a-go-cli-for-260708-edec
+
+**Action:** Refreshed command, REST API controller, route, MCP/CLI boundary, and CLI wiki coverage after the `add-a-go-cli-for-260708-edec` branch updated wiki-facing command/API surface documentation.
+**Pages updated:** `wiki/commands.md`, `wiki/controllers/api-controllers.md`, `wiki/routes.md`, `wiki/mcp-tools.md`
+**Pages already current:** `wiki/api-cli.md`, `wiki/index.md`, `wiki/gaps.md`, `wiki/llm-wiki-maintenance.md`
+**Source:** `git show add-a-go-cli-for-260708-edec`, `config/routes.rb`, `app/controllers/api/base_controller.rb`, `app/controllers/api/v1/*`, `app/serializers/api/v1/contract_serializer.rb`, `app/services/api/v1/project_scope.rb`, `cmd/screenote`, `internal/cli`, and `internal/screenote`.
+**Notes:** Kept wiki edits in the main checkout only. Used `qmd search` and source inspection; did not run `qmd update` or `qmd embed`.
diff --git a/wiki/log.d/20260709T145601Z-local-ci-bootstrap.md b/wiki/log.d/20260709T145601Z-local-ci-bootstrap.md
index f0225f0..c4a4e0c 100644
--- a/wiki/log.d/20260709T145601Z-local-ci-bootstrap.md
+++ b/wiki/log.d/20260709T145601Z-local-ci-bootstrap.md
@@ -5,6 +5,6 @@ slug: local-ci-bootstrap
**Action:** Improved local CI bootstrap and dependency checks.
-**Files changed:** `bin/ci`, `bin/setup`, `config/ci.rb`, `.github/workflows/ci.yml`, `.gitignore`, `Gemfile.lock`, `test/test_helper.rb`, `test/jobs/screenshot_dimension_job_test.rb`, `test/services/annotation_crop_service_test.rb`
+**Files changed:** `bin/ci`, `bin/setup`, `bin/check_coverage`, `config/ci.rb`, `.github/workflows/ci.yml`, `.gitignore`, `Gemfile.lock`, `test/test_helper.rb`, `test/jobs/screenshot_dimension_job_test.rb`, `test/services/annotation_crop_service_test.rb`
-**Notes:** `bin/ci` now installs missing gems before Rails boot, stores bundles in `vendor/bundle`, runs whitespace checks, keeps GitHub push CI aligned to `main`, and runs Go tests when `go.mod` is present. The lockfile was refreshed for current `bundler-audit` advisories and Brakeman freshness. GitHub CI installs `libvips`; local image-processing tests skip with an explicit message when the system library is unavailable.
+**Notes:** `bin/ci` now installs missing gems before Rails boot, stores bundles in `vendor/bundle`, runs whitespace checks, keeps GitHub push CI aligned to `main`, and runs Go tests when `go.mod` is present. Set `REQUIRE_COVERAGE=true` to run Rails tests with SimpleCov and fail below 100% line or branch coverage via `bin/check_coverage`; coverage runs force one worker for stable measurement, and `PARALLEL_WORKERS` can override normal Rails test parallelism. The lockfile was refreshed for current `bundler-audit` advisories, Brakeman freshness, and Playwright protocol compatibility. GitHub CI installs `libvips` and compatible Playwright browsers for system tests, runs Capybara against its in-process Rails server when `CAPYBARA_RUN_SERVER=true`, serializes system tests with `PARALLEL_WORKERS=1`, and caps the system-test job at 15 minutes. System fixtures now include the seed-equivalent `<email>`, `<email>`, and `Demo Project` records expected by browser tests. Browser API/MCP helpers derive the live Capybara server URL, invitation tests inspect test mail deliveries instead of letter_opener files, and mailer preview assertions live in mailer tests; local image-processing tests skip with an explicit message when the system library is unavailable.
diff --git a/wiki/log.md b/wiki/log.md
index c06d2c3..22a419d 100644
--- a/wiki/log.md
+++ b/wiki/log.md
@@ -1,16 +1,98 @@
----
-title: Wiki Changelog
-type: architecture
-source: wiki/
-created: 2026-04-10
-updated: 2026-04-11
-tags: [log, changelog]
----
-
# Wiki Changelog
Append-only log of all wiki operations.
+<!-- BEGIN GENERATED WIKI LOG FRAGMENTS -->
+## [2026-07-08T19:42:13Z] add-a-go-cli-for-260708-edec
+
+**Action:** Refreshed command, REST API controller, route, MCP/CLI boundary, and CLI wiki coverage after the `add-a-go-cli-for-260708-edec` branch updated wiki-facing command/API surface documentation.
+**Pages updated:** `wiki/commands.md`, `wiki/controllers/api-controllers.md`, `wiki/routes.md`, `wiki/mcp-tools.md`
+**Pages already current:** `wiki/api-cli.md`, `wiki/index.md`, `wiki/gaps.md`, `wiki/llm-wiki-maintenance.md`
+**Source:** `git show add-a-go-cli-for-260708-edec`, `config/routes.rb`, `app/controllers/api/base_controller.rb`, `app/controllers/api/v1/*`, `app/serializers/api/v1/contract_serializer.rb`, `app/services/api/v1/project_scope.rb`, `cmd/screenote`, `internal/cli`, and `internal/screenote`.
+**Notes:** Kept wiki edits in the main checkout only. Used `qmd search` and source inspection; did not run `qmd update` or `qmd embed`.
+
+## [2026-07-08T19:22:29Z] add-a-go-cli-for-260708-edec
+
+**Action:** Refreshed main-checkout wiki coverage after the `add-a-go-cli-for-260708-edec` residual finalizer commit changed only branch-local wiki files, deleted branch-local log fragments/maintenance coverage, and simplified some CLI/API wiki wording.
+**Pages updated:** `wiki/gaps.md`
+**Pages verified current:** `wiki/api-cli.md`, `wiki/commands.md`, `wiki/controllers/api-controllers.md`, `wiki/index.md`, `wiki/llm-wiki-maintenance.md`
+**Source:** `git show add-a-go-cli-for-260708-edec`, `git show add-a-go-cli-for-260708-edec:internal/cli/annotation.go`, `git show add-a-go-cli-for-260708-edec:internal/cli/screenshot.go`, `git show add-a-go-cli-for-260708-edec:internal/cli/root.go`, `git show add-a-go-cli-for-260708-edec:internal/cli/errors.go`, `git show add-a-go-cli-for-260708-edec:app/controllers/api/base_controller.rb`, `git show add-a-go-cli-for-260708-edec:app/controllers/api/v1/*`, `git show add-a-go-cli-for-260708-edec:app/serializers/api/v1/contract_serializer.rb`, and `git show add-a-go-cli-for-260708-edec:config/routes.rb`.
+**Uncertainty:** The configured cross-project wiki path `<local-path>` is absent and `qmd search` returned no matching results for the CLI/API refresh query. The branch-local wiki maintenance/log rollback still conflicts with the main-checkout wrapper instruction to add fragments and avoid direct compiled `wiki/log.md` edits.
+**Notes:** Kept wiki edits in `<screenote-repository>/wiki/` only. Did not run `qmd update` or `qmd embed`; did not edit compiled `wiki/log.md`.
+
+## [2026-07-08T19:20:03Z] add-a-go-cli-for-260708-edec
+
+**Action:** Refreshed main-checkout wiki coverage after the `add-a-go-cli-for-260708-edec` finalizer commit changed branch-local wiki files and removed branch-local log fragments/maintenance coverage without changing the committed CLI/API source behavior.
+**Pages updated:** `wiki/gaps.md`
+**Pages verified current:** `wiki/api-cli.md`, `wiki/commands.md`, `wiki/controllers/api-controllers.md`, `wiki/routes.md`, `wiki/mcp-tools.md`, `wiki/index.md`, `wiki/llm-wiki-maintenance.md`
+**Source:** `git show add-a-go-cli-for-260708-edec`, `git show add-a-go-cli-for-260708-edec:README.md`, `git show add-a-go-cli-for-260708-edec:cmd/screenote/main.go`, `git show add-a-go-cli-for-260708-edec:internal/cli/*`, `git show add-a-go-cli-for-260708-edec:internal/screenote/*`, `git show add-a-go-cli-for-260708-edec:app/controllers/api/base_controller.rb`, `git show add-a-go-cli-for-260708-edec:app/controllers/api/v1/*`, `git show add-a-go-cli-for-260708-edec:app/serializers/api/v1/contract_serializer.rb`, `git show add-a-go-cli-for-260708-edec:app/services/api/v1/project_scope.rb`, and `git show add-a-go-cli-for-260708-edec:config/routes.rb`.
+**Uncertainty:** `qmd search` returned no matching results for the CLI/API refresh query, and the configured cross-project wiki path plus default fallback paths were absent. The branch-local maintenance/log rollback still conflicts with the main-checkout fragment-based refresh instructions, so this refresh kept the main checkout's fragment policy and did not edit compiled `wiki/log.md`.
+**Notes:** Kept wiki edits in `<screenote-repository>/wiki/` only. Did not run `qmd update` or `qmd embed`.
+
+## [2026-07-08T19:16:02Z] add-a-go-cli-for-260708-edec
+
+**Action:** Refreshed main-checkout wiki coverage after the `add-a-go-cli-for-260708-edec` residual 6-review commit removed or simplified branch-local wiki details without changing the committed CLI/API source behavior.
+**Pages updated:** `wiki/gaps.md`
+**Pages verified current:** `wiki/api-cli.md`, `wiki/commands.md`, `wiki/controllers/api-controllers.md`, `wiki/index.md`, `wiki/llm-wiki-maintenance.md`
+**Source:** `git show add-a-go-cli-for-260708-edec`, `git show add-a-go-cli-for-260708-edec:internal/cli/annotation.go`, `git show add-a-go-cli-for-260708-edec:internal/cli/screenshot.go`, `git show add-a-go-cli-for-260708-edec:internal/cli/root.go`, `git show add-a-go-cli-for-260708-edec:app/controllers/api/base_controller.rb`, `git show add-a-go-cli-for-260708-edec:app/controllers/api/v1/screenshots_controller.rb`, and current main-checkout wiki pages.
+**Uncertainty:** `qmd search` returned no matching results for the CLI/API refresh query, and the configured cross-project wiki path plus default fallback paths were absent. The branch-local maintenance/log rollback still conflicts with the main-checkout fragment-based log policy.
+**Notes:** Kept edits in `<screenote-repository>/wiki/` only. Did not run `qmd update` or `qmd embed`; did not edit compiled `wiki/log.md`.
+
+## [2026-07-08T19:14:11Z] add-a-go-cli-for-260708-edec
+
+**Action:** Refreshed main-checkout wiki coverage after the `add-a-go-cli-for-260708-edec` residual 6-review commit changed only branch-local wiki files while the committed CLI/API source tree still supports the richer documented command and REST API surface.
+**Pages updated:** `wiki/gaps.md`
+**Pages verified current:** `wiki/api-cli.md`, `wiki/commands.md`, `wiki/controllers/api-controllers.md`, `wiki/routes.md`, `wiki/mcp-tools.md`, `wiki/index.md`, `wiki/llm-wiki-maintenance.md`
+**Source:** `git show add-a-go-cli-for-260708-edec`, `README.md`, `cmd/screenote/main.go`, `internal/cli/annotation.go`, `internal/cli/screenshot.go`, `internal/cli/root.go`, `internal/cli/errors.go`, `internal/screenote/client.go`, `internal/screenote/types.go`, `app/controllers/api/base_controller.rb`, `app/controllers/api/v1/*`, `app/serializers/api/v1/contract_serializer.rb`, and `config/routes.rb`.
+**Uncertainty:** `qmd search` returned no matching results for the CLI/API refresh query, and the configured cross-project wiki path remained unavailable. The branch-local wiki maintenance/log rollback conflicts with the main-checkout wrapper instruction to add fragments and avoid direct compiled `wiki/log.md` edits.
+**Notes:** Kept edits in `<screenote-repository>/wiki/` only. Did not run `qmd update` or `qmd embed`; did not edit compiled `wiki/log.md`.
+
+## [2026-07-08T18:54:31Z] add-a-go-cli-for-260708-edec
+
+**Action:** Refreshed main-checkout wiki coverage after the `add-a-go-cli-for-260708-edec` pass-02 review-fix commit edited CLI/API wiki wording, removed branch-local `wiki/log.d/` fragments, deleted branch-local `wiki/llm-wiki-maintenance.md`, and rewrote branch-local compiled `wiki/log.md`.
+**Pages updated:** `wiki/gaps.md`
+**Pages verified current:** `wiki/api-cli.md`, `wiki/commands.md`, `wiki/controllers/api-controllers.md`, `wiki/routes.md`, `wiki/mcp-tools.md`, `wiki/index.md`, `wiki/llm-wiki-maintenance.md`
+**Source:** `git show add-a-go-cli-for-260708-edec`, `git show add-a-go-cli-for-260708-edec:cmd/screenote/main.go`, `git show add-a-go-cli-for-260708-edec:internal/cli/annotation.go`, `git show add-a-go-cli-for-260708-edec:internal/cli/screenshot.go`, `git show add-a-go-cli-for-260708-edec:internal/cli/root.go`, `git show add-a-go-cli-for-260708-edec:app/controllers/api/base_controller.rb`, `git show add-a-go-cli-for-260708-edec:app/controllers/api/v1/*`, `git show add-a-go-cli-for-260708-edec:app/serializers/api/v1/contract_serializer.rb`, `git show add-a-go-cli-for-260708-edec:app/services/api/v1/project_scope.rb`, and `git show add-a-go-cli-for-260708-edec:config/routes.rb`.
+**Uncertainty:** The configured cross-project wiki path and default fallback paths were absent, and `qmd search` returned no matching project-wiki results. The branch-local wiki maintenance/log policy still conflicts with the main-checkout wrapper instructions, so this refresh kept the main checkout's fragment-based log policy and did not edit compiled `wiki/log.md`.
+**Notes:** Kept wiki edits in `<screenote-repository>/wiki/` only. Did not run `qmd update` or `qmd embed`.
+
+## [2026-07-08T18:51:56Z] add-a-go-cli-for-260708-edec
+
+**Action:** Refreshed main-checkout CLI/API wiki coverage after the `add-a-go-cli-for-260708-edec` review-pass commit changed wiki-facing CLI/API wording and removed wiki maintenance artifacts from its own tree.
+**Pages updated:** `wiki/api-cli.md`
+**Pages verified current:** `wiki/commands.md`, `wiki/controllers/api-controllers.md`, `wiki/routes.md`, `wiki/mcp-tools.md`, `wiki/index.md`, `wiki/gaps.md`, `wiki/llm-wiki-maintenance.md`
+**Source:** `git show add-a-go-cli-for-260708-edec`, `git show add-a-go-cli-for-260708-edec:README.md`, `git show add-a-go-cli-for-260708-edec:cmd/screenote/main.go`, `git show add-a-go-cli-for-260708-edec:internal/cli/annotation.go`, `git show add-a-go-cli-for-260708-edec:internal/cli/screenshot.go`, `git show add-a-go-cli-for-260708-edec:internal/cli/root.go`, `git show add-a-go-cli-for-260708-edec:internal/cli/commands_test.go`, `git show add-a-go-cli-for-260708-edec:internal/screenote/client.go`, `git show add-a-go-cli-for-260708-edec:internal/screenote/types.go`, `git show add-a-go-cli-for-260708-edec:app/controllers/api/base_controller.rb`, `git show add-a-go-cli-for-260708-edec:app/controllers/api/v1/*`, `git show add-a-go-cli-for-260708-edec:app/serializers/api/v1/contract_serializer.rb`, and `git show add-a-go-cli-for-260708-edec:config/routes.rb`.
+**Uncertainty:** The configured cross-project wiki path and default fallback paths were absent; `qmd search` returned no matching project-wiki results. The branch tree still conflicts with current main-checkout instructions on `wiki/log.d/` fragments and compiled `wiki/log.md`, so this refresh followed the wrapper instruction to add a fragment and avoid editing compiled `wiki/log.md`.
+**Notes:** Kept wiki edits in `<screenote-repository>/wiki/` only. Did not run `qmd update` or `qmd embed`.
+
+## [2026-07-08T18:47:28Z] add-a-go-cli-for-260708-edec
+
+**Action:** Refreshed main-checkout wiki coverage after inspecting the `add-a-go-cli-for-260708-edec` branch wiki commit and the committed REST/Go CLI source tree it documents.
+**Pages updated:** `wiki/gaps.md`
+**Pages verified current:** `wiki/api-cli.md`, `wiki/commands.md`, `wiki/controllers/api-controllers.md`, `wiki/routes.md`, `wiki/mcp-tools.md`, `wiki/index.md`, `wiki/llm-wiki-maintenance.md`
+**Source:** `git show add-a-go-cli-for-260708-edec`, `git show add-a-go-cli-for-260708-edec:config/routes.rb`, `git show add-a-go-cli-for-260708-edec:app/controllers/api/base_controller.rb`, `git show add-a-go-cli-for-260708-edec:app/controllers/api/v1/*`, `git show add-a-go-cli-for-260708-edec:app/serializers/api/v1/contract_serializer.rb`, `git show add-a-go-cli-for-260708-edec:internal/cli/annotation.go`, `git show add-a-go-cli-for-260708-edec:internal/cli/screenshot.go`, and current main-checkout wiki pages.
+**Uncertainty:** The branch commit deletes `wiki/log.d/` fragments and `wiki/llm-wiki-maintenance.md` in its own tree, but current main-checkout instructions require log fragments and wrapper-owned compiled `wiki/log.md`; recorded this policy mismatch in `wiki/gaps.md`.
+**Notes:** Kept edits under `<screenote-repository>/wiki/` only. Used `qmd search`; did not run `qmd update` or `qmd embed`; did not edit compiled `wiki/log.md`.
+
+## [2026-07-08T18:23:02Z] add-a-go-cli-for-260708-edec
+
+**Action:** Refreshed wiki coverage after the `add-a-go-cli-for-260708-edec` branch corrected Go CLI annotation aggregation, usage exits, upload content types, compact JSON output, and CLI docs.
+**Pages created:** `wiki/api-cli.md`
+**Pages updated:** `wiki/index.md`, `wiki/gaps.md`
+**Source:** `git show add-a-go-cli-for-260708-edec`, committed `internal/cli/annotation.go`, `internal/cli/root.go`, `internal/cli/screenshot.go`, `internal/cli/commands_test.go`, and committed `wiki/api-cli.md`
+**Notes:** Did not edit compiled `wiki/log.md`; this fragment is for the post-commit wrapper to compile. Did not run `qmd update` or `qmd embed`.
+
+## [2026-07-08T15:34:48Z] refresh
+
+**Action:** Refreshed project wiki against current LLM-wiki automation and recent git history.
+**Pages created:** `wiki/llm-wiki-maintenance.md`
+**Pages updated:** `wiki/index.md`, `wiki/gaps.md`
+**Pages unchanged after source verification:** core architecture/model/controller/MCP pages; recent commits after the prior refresh changed LLM-wiki automation and context files, not application source behavior.
+**Cross-project wiki:** `.llm-wiki/config.json` still points at `<local-path>`, but that path and the default fallback main wiki paths were absent on this machine during refresh.
+**QMD:** Used `qmd search` only; did not run `qmd update` or `qmd embed`.
+**Source:** `.llm-wiki/config.json`, `AGENTS.md`, `CLAUDE.md`, `.llm-wiki/refresh-wiki.sh`, `.llm-wiki/post-commit-refresh.sh`, `.llm-wiki/compile-log.sh`, `.claude/settings.json`, recent `git log`, source inventory under `app/`, `config/`, `db/`, `plans/`, and `todos/`.
+<!-- END GENERATED WIKI LOG FRAGMENTS -->
+
## 2026-04-10 -- Bootstrap
**Author:** Claude (LLM wiki bootstrap)
diff --git a/wiki/mcp-tools.md b/wiki/mcp-tools.md
index 7f9581b..cd7e038 100644
--- a/wiki/mcp-tools.md
+++ b/wiki/mcp-tools.md
@@ -23,6 +23,8 @@ Source: `app/tools/**/*.rb`, `config/initializers/fast_mcp.rb`
The transport rate-limits API keys and OAuth tokens at 60 requests/minute, logs validation failures, reports unexpected errors to Honeybadger, and returns a JSON 429 for rate-limited requests.
+The Go CLI in [[api-cli]] does not call MCP. It uses REST `api/v1` so shell and CI users can automate Screenote without an MCP client.
+
## Tool Inventory
| Tool | Source | Purpose |
diff --git a/wiki/routes.md b/wiki/routes.md
index 595c0c0..58c77bf 100644
--- a/wiki/routes.md
+++ b/wiki/routes.md
@@ -123,8 +123,15 @@ All require authentication unless noted.
| Method | Path | Action | Auth |
|--------|------|--------|------|
-| POST | `/api/v1/screenshots` | Upload screenshot | API Key |
-| GET | `/api/v1/screenshots/:screenshot_id/annotations` | List annotations | API Key |
+| GET | `/api/v1/projects` | List the project bound to the API key | API Key |
+| GET | `/api/v1/projects/:project_id/pages` | List pages with version counts | API Key |
+| GET | `/api/v1/projects/:project_id/screenshots` | List screenshots with `page_id`, `status`, `limit`, and `offset` filters | API Key |
+| POST | `/api/v1/screenshots` | Direct multipart screenshot upload | API Key |
+| GET | `/api/v1/screenshots/:screenshot_id/annotations` | List annotations with `status`, `viewport`, `limit`, and `offset` filters | API Key |
+| GET | `/api/v1/annotations/:id` | Get annotation details, comments, and best-effort crop data | API Key |
+| POST | `/api/v1/annotations/:annotation_id/comments` | Add an API-key-authored annotation comment | API Key |
+
+API-key auth is project-scoped. If `:project_id` does not match the key's project, the v1 API returns a stable JSON error with `code: "forbidden"` rather than crossing project boundaries.
### Upload API (Token-based, no persistent auth)