slidict 0.2.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,271 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Slidict
4
+ module Cli
5
+ # Implements the `slidict slides <list|show|create|edit>` subcommands.
6
+ class Slides
7
+ def initialize(output:, credentials: nil, client: nil, reauthenticate: nil)
8
+ @output = output
9
+ @credentials = credentials || External::SlidictIo::Credentials.new
10
+ @client = client
11
+ @reauthenticate = reauthenticate
12
+ end
13
+
14
+ def run(argv)
15
+ options = parse(argv)
16
+ return print_help if options[:help] || options[:subcommand].nil?
17
+
18
+ send(options[:subcommand], options)
19
+ rescue ArgumentError => e
20
+ @output.puts "Error: #{e.message}"
21
+ @output.puts
22
+ print_help
23
+ 1
24
+ end
25
+
26
+ # Creates or edits a draft directly, bypassing argv parsing (and its
27
+ # "looks like a flag" checks, which would reject body text such as
28
+ # YAML frontmatter that happens to start with "-").
29
+ def publish(body:, id: nil, title: nil, body_format: nil, visibility: nil)
30
+ options = { id: id, title: title, body: body, body_format: body_format, visibility: visibility }
31
+ id ? edit(options) : create(options)
32
+ end
33
+
34
+ private
35
+
36
+ def parse(argv)
37
+ args = argv.dup
38
+ subcommand = args.shift
39
+ return { help: true } if subcommand.nil? || %w[-h --help].include?(subcommand)
40
+
41
+ case subcommand
42
+ when "list" then parse_list(args)
43
+ when "show" then parse_show(args)
44
+ when "create" then parse_create(args)
45
+ when "edit" then parse_edit(args)
46
+ else raise ArgumentError, "unknown slides command #{subcommand}"
47
+ end
48
+ end
49
+
50
+ def parse_list(args)
51
+ options = { subcommand: :list }
52
+ until args.empty?
53
+ case (arg = args.shift)
54
+ when "--page" then options[:page] = Integer(fetch_value!(args, arg))
55
+ when "-h", "--help" then options[:help] = true
56
+ else raise ArgumentError, "unknown option #{arg}"
57
+ end
58
+ end
59
+ options
60
+ end
61
+
62
+ def parse_show(args)
63
+ id = args.shift
64
+ raise ArgumentError, "show requires a slide id" if id.nil?
65
+ raise ArgumentError, "unknown option #{args.first}" unless args.empty?
66
+
67
+ { subcommand: :show, id: id }
68
+ end
69
+
70
+ def parse_create(args)
71
+ options = { subcommand: :create }
72
+ parse_body_options!(args, options)
73
+ options
74
+ end
75
+
76
+ def parse_edit(args)
77
+ id = args.shift
78
+ raise ArgumentError, "edit requires a slide id" if id.nil?
79
+
80
+ options = { subcommand: :edit, id: id }
81
+ parse_body_options!(args, options)
82
+ options
83
+ end
84
+
85
+ def parse_body_options!(args, options)
86
+ until args.empty?
87
+ case (arg = args.shift)
88
+ when "--title" then options[:title] = fetch_value!(args, arg)
89
+ when "--body" then options[:body] = fetch_value!(args, arg)
90
+ when "--file" then options[:file] = fetch_value!(args, arg)
91
+ when "--body-format" then options[:body_format] = fetch_value!(args, arg)
92
+ when "--visibility" then options[:visibility] = fetch_value!(args, arg)
93
+ when "-h", "--help" then options[:help] = true
94
+ else raise ArgumentError, "unknown option #{arg}"
95
+ end
96
+ end
97
+ raise ArgumentError, "specify only one of --body or --file" if options[:body] && options[:file]
98
+ end
99
+
100
+ # Does not reject values starting with "-": body text or titles may
101
+ # legitimately start with a dash (e.g. YAML frontmatter), and a genuinely
102
+ # missing value still surfaces as an "unknown option" error from the
103
+ # next iteration of the parse loop, or a nil check below.
104
+ def fetch_value!(args, option)
105
+ value = args.shift
106
+ raise ArgumentError, "#{option} requires a value" if value.nil?
107
+
108
+ value
109
+ end
110
+
111
+ def list(options)
112
+ with_reauth_retry do
113
+ print_slide_list(client.list(page: options[:page]))
114
+ 0
115
+ end
116
+ end
117
+
118
+ def show(options)
119
+ with_reauth_retry do
120
+ print_slide_detail(client.show(options[:id]))
121
+ 0
122
+ rescue External::SlidictIo::Client::NotFound
123
+ @output.puts "Error: slide not found"
124
+ 1
125
+ end
126
+ end
127
+
128
+ def create(options)
129
+ body = read_body(options)
130
+ raise ArgumentError, "create requires --body or --file" if body.to_s.strip.empty?
131
+
132
+ submit("Created") do
133
+ client.create(title: options[:title], body: body, body_format: options[:body_format],
134
+ visibility: options[:visibility])
135
+ end
136
+ end
137
+
138
+ def edit(options)
139
+ submit("Updated") do
140
+ client.update(options[:id], title: options[:title], body: read_body(options),
141
+ body_format: options[:body_format], visibility: options[:visibility])
142
+ end
143
+ end
144
+
145
+ def submit(verb)
146
+ with_reauth_retry do
147
+ slide = yield
148
+ @output.puts "#{verb} slide ##{slide["id"]} (draft)"
149
+ print_slide_detail(slide)
150
+ 0
151
+ rescue External::SlidictIo::Client::NotFound
152
+ @output.puts "Error: slide not found"
153
+ 1
154
+ rescue External::SlidictIo::Client::NotEditable
155
+ @output.puts "Error: this slide is already published. Edit it from the Web UI instead."
156
+ 1
157
+ rescue External::SlidictIo::Client::RateLimited
158
+ print_rate_limited
159
+ rescue External::SlidictIo::Client::Unprocessable => e
160
+ print_unprocessable(e)
161
+ end
162
+ end
163
+
164
+ # Shared by list/show/submit: on a 401 from an expired/invalid token,
165
+ # silently re-authenticate once (via the injected reauthenticate flow)
166
+ # and retry the same request before giving up.
167
+ def with_reauth_retry
168
+ reauthenticated = false
169
+ begin
170
+ yield
171
+ rescue External::SlidictIo::Client::Unauthorized => e
172
+ if !reauthenticated && reauthenticate!
173
+ reauthenticated = true
174
+ retry
175
+ end
176
+ print_client_error(e)
177
+ rescue External::SlidictIo::Client::Error => e
178
+ print_client_error(e)
179
+ end
180
+ end
181
+
182
+ def read_body(options)
183
+ return options[:body] unless options[:file]
184
+
185
+ File.read(options[:file])
186
+ rescue Errno::ENOENT, Errno::EACCES => e
187
+ raise ArgumentError, "could not read #{options[:file]}: #{e.message}"
188
+ end
189
+
190
+ def client
191
+ @client ||= begin
192
+ token = @credentials.read_cli_token
193
+ token = @credentials.read_cli_token if token.nil? && reauthenticate!
194
+ raise ArgumentError, "not authenticated. Run `slidict auth` first." unless token
195
+
196
+ External::SlidictIo::Client.new(access_token: token[:access_token], token_type: token[:token_type])
197
+ end
198
+ end
199
+
200
+ # Triggers the injected login flow (see Slidict::Cli::App#auth) and clears the
201
+ # memoized client so the next call to `client` picks up the fresh token.
202
+ def reauthenticate!
203
+ return false unless @reauthenticate
204
+
205
+ @client = nil
206
+ @reauthenticate.call.zero?
207
+ end
208
+
209
+ def print_slide_list(result)
210
+ slides = result["slides"] || []
211
+ if slides.empty?
212
+ @output.puts "No slides found."
213
+ return
214
+ end
215
+
216
+ slides.each do |slide|
217
+ @output.puts "##{slide["id"]} [#{slide["status"]}/#{slide["visibility"]}] " \
218
+ "#{slide["title"]} (updated_at: #{slide["updated_at"]})"
219
+ end
220
+ @output.puts "(more slides available, use --page to see the next page)" if result["has_more"]
221
+ end
222
+
223
+ def print_slide_detail(slide)
224
+ @output.puts "##{slide["id"]} #{slide["title"]}"
225
+ @output.puts "status: #{slide["status"]} visibility: #{slide["visibility"]} " \
226
+ "updated_at: #{slide["updated_at"]}"
227
+ @output.puts
228
+ @output.puts slide["body"]
229
+ end
230
+
231
+ def print_rate_limited
232
+ @output.puts "Error: rate limited. Create/edit is limited to once per minute. Wait and try again."
233
+ 1
234
+ end
235
+
236
+ def print_unprocessable(error)
237
+ @output.puts "Error: #{error.message}"
238
+ error.errors.each { |message| @output.puts " - #{message}" }
239
+ 1
240
+ end
241
+
242
+ def print_client_error(error)
243
+ @output.puts "Error: #{error.message}"
244
+ 1
245
+ end
246
+
247
+ def print_help
248
+ @output.puts <<~HELP
249
+ Usage: slidict slides <command> [options]
250
+
251
+ Commands:
252
+ list [--page N] List your slides (20 per page)
253
+ show <id> Show a slide's details and body
254
+ create [options] Create a new draft slide
255
+ edit <id> [options] Edit an existing draft slide
256
+
257
+ Create/edit options:
258
+ --title TEXT Slide title
259
+ --body TEXT Slide body text
260
+ --file PATH Read the slide body from a file (instead of --body)
261
+ --body-format FORMAT asciidoc or markdown (default: auto-detected from body)
262
+ --visibility VIS public, unlisted, or group_only (default: public)
263
+ -h, --help Show this help
264
+
265
+ Note: slides are always created/edited as drafts. Publish from the Web UI.
266
+ HELP
267
+ 0
268
+ end
269
+ end
270
+ end
271
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "uri"
6
+
7
+ module Slidict
8
+ module External
9
+ module SlidictIo
10
+ class Auth
11
+ Error = Class.new(StandardError)
12
+ Pending = Class.new(StandardError)
13
+
14
+ DEFAULT_BASE_URL = "https://slidict.io"
15
+
16
+ attr_reader :base_url
17
+
18
+ def initialize(base_url: ENV.fetch("SLIDICT_AUTH_BASE_URL", DEFAULT_BASE_URL))
19
+ @base_url = base_url
20
+ end
21
+
22
+ def request_device_code
23
+ response = post_json("/api/cli/device/code", { provider: "github" })
24
+ {
25
+ device_code: fetch!(response, "device_code"),
26
+ user_code: fetch!(response, "user_code"),
27
+ verification_uri: response["verification_uri"] || "#{base_url}/cli/activate",
28
+ interval: response.fetch("interval", 5).to_i,
29
+ expires_in: response.fetch("expires_in", 600).to_i
30
+ }
31
+ end
32
+
33
+ def poll_token(device_code:)
34
+ response = post_json(
35
+ "/api/cli/device/token",
36
+ { device_code: device_code, grant_type: "urn:ietf:params:oauth:grant-type:device_code" },
37
+ raise_on_http_error: false
38
+ )
39
+ return response if response["access_token"]
40
+
41
+ error = response["error"].to_s
42
+ raise Pending if %w[authorization_pending slow_down].include?(error)
43
+
44
+ raise Error, response["error_description"] || error unless error.empty?
45
+
46
+ raise Error, "token response did not include access_token"
47
+ end
48
+
49
+ private
50
+
51
+ def post_json(path, payload, raise_on_http_error: true)
52
+ uri = URI.join(base_url, path)
53
+ request = Net::HTTP::Post.new(uri)
54
+ request["Accept"] = "application/json"
55
+ request["Content-Type"] = "application/json"
56
+ request.body = JSON.generate(payload)
57
+
58
+ response = Net::HTTP.start(uri.hostname, uri.port,
59
+ use_ssl: uri.scheme == "https", open_timeout: 5, read_timeout: 30) do |http|
60
+ http.request(request)
61
+ end
62
+ body = response.body.to_s.empty? ? {} : JSON.parse(response.body)
63
+ return body if response.is_a?(Net::HTTPSuccess) || !raise_on_http_error
64
+
65
+ raise Error, body["error_description"] || body["error"] || "HTTP #{response.code}"
66
+ rescue JSON::ParserError => e
67
+ raise Error, "invalid JSON response: #{e.message}"
68
+ rescue SystemCallError, Timeout::Error => e
69
+ raise Error, e.message
70
+ end
71
+
72
+ def fetch!(hash, key)
73
+ hash.fetch(key)
74
+ rescue KeyError
75
+ raise Error, "device code response did not include #{key}"
76
+ end
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "uri"
6
+
7
+ module Slidict
8
+ module External
9
+ module SlidictIo
10
+ # Talks to the slidict.io CLI slides API using a CLI access token
11
+ # (obtained via Slidict::External::SlidictIo::Auth / `slidict auth`).
12
+ class Client
13
+ class Error < StandardError; end
14
+ class Unauthorized < Error; end
15
+ class Forbidden < Error; end
16
+ class NotFound < Error; end
17
+ class NotEditable < Error; end
18
+ class RateLimited < Error; end
19
+
20
+ # Raised for 422 responses other than "not_editable", carrying the API's validation errors.
21
+ class Unprocessable < Error
22
+ attr_reader :errors
23
+
24
+ def initialize(message, errors: [])
25
+ super(message)
26
+ @errors = errors
27
+ end
28
+ end
29
+
30
+ DEFAULT_BASE_URL = "https://slidict.io"
31
+
32
+ attr_reader :base_url
33
+
34
+ def initialize(access_token:, token_type: "Bearer",
35
+ base_url: ENV.fetch("SLIDICT_AUTH_BASE_URL", DEFAULT_BASE_URL))
36
+ @access_token = access_token
37
+ @token_type = token_type
38
+ @base_url = base_url
39
+ end
40
+
41
+ def list(page: nil)
42
+ get_json("/api/cli/slides", query: page ? { page: page } : {})
43
+ end
44
+
45
+ def show(id)
46
+ get_json("/api/cli/slides/#{id}")
47
+ end
48
+
49
+ def create(body:, title: nil, body_format: nil, visibility: nil)
50
+ post_json("/api/cli/slides",
51
+ slide_payload(title: title, body: body, body_format: body_format, visibility: visibility))
52
+ end
53
+
54
+ def update(id, body: nil, title: nil, body_format: nil, visibility: nil)
55
+ patch_json("/api/cli/slides/#{id}",
56
+ slide_payload(title: title, body: body, body_format: body_format, visibility: visibility))
57
+ end
58
+
59
+ private
60
+
61
+ def slide_payload(title:, body:, body_format:, visibility:)
62
+ { title: title, body: body, body_format: body_format, visibility: visibility }.compact
63
+ end
64
+
65
+ def get_json(path, query: {})
66
+ uri = URI.join(base_url, path)
67
+ uri.query = URI.encode_www_form(query) unless query.empty?
68
+ perform(uri, Net::HTTP::Get.new(uri))
69
+ end
70
+
71
+ def post_json(path, payload)
72
+ uri = URI.join(base_url, path)
73
+ perform(uri, build_request(Net::HTTP::Post.new(uri), payload))
74
+ end
75
+
76
+ def patch_json(path, payload)
77
+ uri = URI.join(base_url, path)
78
+ perform(uri, build_request(Net::HTTP::Patch.new(uri), payload))
79
+ end
80
+
81
+ def build_request(request, payload)
82
+ request["Content-Type"] = "application/json"
83
+ request.body = JSON.generate(payload)
84
+ request
85
+ end
86
+
87
+ def perform(uri, request)
88
+ request["Accept"] = "application/json"
89
+ request["Authorization"] = "#{@token_type} #{@access_token}"
90
+
91
+ response = Net::HTTP.start(uri.hostname, uri.port,
92
+ use_ssl: uri.scheme == "https", open_timeout: 5, read_timeout: 30) do |http|
93
+ http.request(request)
94
+ end
95
+ body = response.body.to_s.empty? ? {} : JSON.parse(response.body)
96
+ handle_response(response, body)
97
+ rescue JSON::ParserError => e
98
+ raise Error, "invalid JSON response: #{e.message}"
99
+ rescue SystemCallError, Timeout::Error => e
100
+ raise Error, e.message
101
+ end
102
+
103
+ ERROR_CLASSES_BY_STATUS = {
104
+ "401" => Unauthorized,
105
+ "403" => Forbidden,
106
+ "404" => NotFound,
107
+ "429" => RateLimited
108
+ }.freeze
109
+
110
+ def handle_response(response, body)
111
+ return body if response.is_a?(Net::HTTPSuccess)
112
+ return raise_unprocessable(body) if response.code == "422"
113
+
114
+ error_class = ERROR_CLASSES_BY_STATUS.fetch(response.code, Error)
115
+ raise error_class, body["error_description"] || body["error"] || "HTTP #{response.code}"
116
+ end
117
+
118
+ def raise_unprocessable(body)
119
+ raise NotEditable, "not_editable" if body["error"] == "not_editable"
120
+
121
+ raise Unprocessable.new(body["error"] || "unprocessable", errors: Array(body["errors"]))
122
+ end
123
+ end
124
+ end
125
+ end
126
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "json"
5
+
6
+ module Slidict
7
+ module External
8
+ module SlidictIo
9
+ class Credentials
10
+ DEFAULT_PATH = File.expand_path("~/.config/slidict/credentials.json")
11
+
12
+ def initialize(path: DEFAULT_PATH)
13
+ @path = path
14
+ end
15
+
16
+ def write_cli_token!(access_token:, token_type: "Bearer", provider: "github")
17
+ FileUtils.mkdir_p(File.dirname(@path), mode: 0o700)
18
+ json = JSON.pretty_generate(
19
+ "access_token" => access_token,
20
+ "token_type" => token_type,
21
+ "provider" => provider,
22
+ "kind" => "cli_access_token"
23
+ )
24
+ File.write(@path, "#{json}\n", mode: "w", perm: 0o600)
25
+ File.chmod(0o600, @path)
26
+ @path
27
+ end
28
+
29
+ # Returns { access_token:, token_type: } or nil if no CLI access token is saved.
30
+ def read_cli_token
31
+ return nil unless File.exist?(@path)
32
+
33
+ data = JSON.parse(File.read(@path))
34
+ return nil unless data["kind"] == "cli_access_token" && data["access_token"]
35
+
36
+ { access_token: data["access_token"], token_type: data.fetch("token_type", "Bearer") }
37
+ rescue JSON::ParserError
38
+ nil
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,146 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "uri"
5
+ require "json"
6
+
7
+ module Slidict
8
+ module Llm
9
+ # Talks to any OpenAI Compatible API (OpenAI, Ollama, LM Studio, vLLM, etc.)
10
+ # via the standard /chat/completions endpoint. Configure the target with
11
+ # Slidict::Config (base_url, api_key, model).
12
+ class Client
13
+ class Error < StandardError; end
14
+
15
+ def initialize(base_url:, api_key:, model:)
16
+ @base_url = base_url
17
+ @api_key = api_key
18
+ @model = model
19
+ end
20
+
21
+ # Checks that the endpoint is reachable before the (slower, more
22
+ # expensive) chat completion request is made. Raises Error on failure.
23
+ def verify_connection!
24
+ uri = endpoint_uri("models")
25
+ request = Net::HTTP::Get.new(uri)
26
+ request["Authorization"] = "Bearer #{@api_key}"
27
+ response = perform_request(uri, request)
28
+
29
+ raise Error, "#{response.code} #{response.message}" unless response.is_a?(Net::HTTPSuccess)
30
+ end
31
+
32
+ def generate_slides(deck)
33
+ content = chat_completion(prompt_for(deck))
34
+ slides_from(content)
35
+ end
36
+
37
+ private
38
+
39
+ def prompt_for(deck)
40
+ <<~PROMPT
41
+ You are an assistant that designs presentation slide outlines.
42
+ Topic: #{deck.topic}
43
+ Duration: #{deck.duration}
44
+ Audience: #{deck.audience}
45
+ Goal: #{deck.goal}
46
+
47
+ Return exactly 5 slides as a JSON array. Each item must be an object with
48
+ a "title" string and a "bullets" array of 2-4 short strings.
49
+ Respond with the JSON array only: no commentary, no markdown code fences,
50
+ and no reasoning or thinking content before or after it.
51
+ PROMPT
52
+ end
53
+
54
+ def chat_completion(prompt)
55
+ response = JSON.parse(post_chat_completion(prompt))
56
+ content = response.dig("choices", 0, "message", "content")
57
+ raise Error, "empty response from model" if content.to_s.strip.empty?
58
+
59
+ content
60
+ rescue JSON::ParserError => e
61
+ raise Error, "could not parse model response: #{e.message}"
62
+ end
63
+
64
+ def post_chat_completion(prompt)
65
+ uri = endpoint_uri("chat/completions")
66
+ request = build_request(uri, prompt)
67
+ response = perform_request(uri, request)
68
+
69
+ raise Error, "#{response.code} #{response.message}" unless response.is_a?(Net::HTTPSuccess)
70
+
71
+ response.body
72
+ end
73
+
74
+ def perform_request(uri, request)
75
+ Net::HTTP.start(uri.host, uri.port,
76
+ use_ssl: uri.scheme == "https",
77
+ open_timeout: 5,
78
+ read_timeout: 30,
79
+ write_timeout: 30) do |http|
80
+ http.request(request)
81
+ end
82
+ rescue StandardError => e
83
+ raise Error, e.message
84
+ end
85
+
86
+ def endpoint_uri(path)
87
+ base = @base_url.to_s.sub(%r{/+\z}, "")
88
+ URI.join("#{base}/", path)
89
+ end
90
+
91
+ def build_request(uri, prompt)
92
+ request = Net::HTTP::Post.new(uri)
93
+ request["Content-Type"] = "application/json"
94
+ request["Authorization"] = "Bearer #{@api_key}"
95
+ request.body = JSON.generate(
96
+ model: @model,
97
+ messages: [{ role: "user", content: prompt }],
98
+ temperature: 0.7
99
+ )
100
+ request
101
+ end
102
+
103
+ def slides_from(content)
104
+ parsed = JSON.parse(extract_json_array(content))
105
+ raise Error, "expected a JSON array of slides" unless parsed.is_a?(Array)
106
+
107
+ parsed.map do |item|
108
+ Slide.new(title: item.fetch("title"), bullets: Array(item.fetch("bullets")))
109
+ end
110
+ rescue JSON::ParserError, KeyError => e
111
+ raise Error, "could not parse model response: #{e.message}"
112
+ end
113
+
114
+ # Some models (especially reasoning models served through LM Studio or
115
+ # Ollama) prepend or append thinking/reasoning text around the JSON
116
+ # answer instead of returning it verbatim, so the array is extracted from
117
+ # within the raw content rather than parsed as-is.
118
+ def extract_json_array(content)
119
+ content.enum_for(:scan, /\[/).each do
120
+ start = Regexp.last_match.begin(0)
121
+ candidate = json_array_from(content, start)
122
+ return candidate if candidate
123
+ end
124
+
125
+ raise Error, "no JSON array found in model response"
126
+ end
127
+
128
+ def json_array_from(content, start)
129
+ finish = start
130
+ while (finish = content.index("]", finish))
131
+ candidate = content[start..finish]
132
+ return candidate if parses_to_array?(candidate)
133
+
134
+ finish += 1
135
+ end
136
+ nil
137
+ end
138
+
139
+ def parses_to_array?(candidate)
140
+ JSON.parse(candidate).is_a?(Array)
141
+ rescue JSON::ParserError
142
+ false
143
+ end
144
+ end
145
+ end
146
+ end