slidict 0.2.0 → 0.4.0

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,133 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Slidict
4
+ module Cli
5
+ # Implements `slidict lint <file>`: diagnoses whether a Markdown/Asciidoc
6
+ # slide deck has a structure that an audience can actually follow (not
7
+ # whether it looks nice). Diagnosis only -- it does not rewrite the file.
8
+ class Lint
9
+ ASCIIDOC_EXTENSIONS = %w[.adoc .asciidoc].freeze
10
+
11
+ def initialize(output:, linter_factory: nil, renderer: Slidict::Lint::Renderer.new)
12
+ @output = output
13
+ @linter_factory = linter_factory || method(:default_linter)
14
+ @renderer = renderer
15
+ end
16
+
17
+ def run(argv)
18
+ options = parse(argv)
19
+ return print_help if options[:help] || options[:path].nil?
20
+ return file_not_found(options[:path]) unless File.exist?(options[:path])
21
+
22
+ run_lint(options)
23
+ rescue ArgumentError => e
24
+ print_usage_error(e)
25
+ rescue Slidict::Lint::Linter::Error, Llm::Client::Error => e
26
+ print_error(e)
27
+ end
28
+
29
+ private
30
+
31
+ def run_lint(options)
32
+ config = build_config(options)
33
+ return llm_required unless config.llm_enabled?
34
+
35
+ print_findings(lint(options, config))
36
+ end
37
+
38
+ def lint(options, config)
39
+ @linter_factory.call(config).lint(File.read(options[:path]), format: format_for(options))
40
+ end
41
+
42
+ def print_usage_error(error)
43
+ print_error(error)
44
+ @output.puts
45
+ print_help
46
+ 1
47
+ end
48
+
49
+ def print_error(error)
50
+ @output.puts "Error: #{error.message}"
51
+ 1
52
+ end
53
+
54
+ def parse(argv)
55
+ args = argv.dup
56
+ options = { path: extract_path!(args) }
57
+ parse_options!(args, options)
58
+ options
59
+ end
60
+
61
+ def extract_path!(args)
62
+ args.shift unless args.first.to_s.start_with?("-")
63
+ end
64
+
65
+ def parse_options!(args, options)
66
+ until args.empty?
67
+ case (arg = args.shift)
68
+ when "-h", "--help" then options[:help] = true
69
+ when "--format" then options[:format] = fetch_value!(args, arg)
70
+ when "--llm-base-url" then options[:llm_base_url] = fetch_value!(args, arg)
71
+ when "--llm-api-key" then options[:llm_api_key] = fetch_value!(args, arg)
72
+ when "--llm-model" then options[:llm_model] = fetch_value!(args, arg)
73
+ else raise ArgumentError, "unknown option #{arg}"
74
+ end
75
+ end
76
+ end
77
+
78
+ def fetch_value!(args, option)
79
+ value = args.shift
80
+ raise ArgumentError, "#{option} requires a value" if value.nil? || value.start_with?("-")
81
+
82
+ value
83
+ end
84
+
85
+ def build_config(options)
86
+ Config.from_env.merge(
87
+ base_url: options[:llm_base_url],
88
+ api_key: options[:llm_api_key],
89
+ model: options[:llm_model]
90
+ )
91
+ end
92
+
93
+ def default_linter(config)
94
+ client = Llm::Client.new(base_url: config.base_url, api_key: config.api_key, model: config.model)
95
+ Slidict::Lint::Linter.new(client: client)
96
+ end
97
+
98
+ def format_for(options)
99
+ return options[:format] if options[:format]
100
+
101
+ ASCIIDOC_EXTENSIONS.include?(File.extname(options[:path]).downcase) ? "asciidoc" : "markdown"
102
+ end
103
+
104
+ def print_findings(findings)
105
+ @output.puts(findings.empty? ? "No issues found." : @renderer.render(findings))
106
+ 0
107
+ end
108
+
109
+ def file_not_found(path)
110
+ @output.puts "Error: file not found: #{path}"
111
+ 1
112
+ end
113
+
114
+ def llm_required
115
+ @output.puts "Error: lint requires an LLM endpoint (--llm-base-url or SLIDICT_LLM_BASE_URL)"
116
+ 1
117
+ end
118
+
119
+ def print_help
120
+ @output.puts <<~HELP
121
+ Usage: slidict lint <file> [options]
122
+ Diagnoses whether a slide deck's structure will land with its audience.
123
+ --format FORMAT markdown or asciidoc (default: auto-detected from extension)
124
+ --llm-base-url URL OpenAI Compatible API base URL (env: SLIDICT_LLM_BASE_URL)
125
+ --llm-api-key KEY API key for the LLM endpoint (env: SLIDICT_LLM_API_KEY)
126
+ --llm-model NAME Model name to request (env: SLIDICT_LLM_MODEL, default: gpt-4o-mini)
127
+ -h, --help Show this help
128
+ HELP
129
+ 0
130
+ end
131
+ end
132
+ end
133
+ end
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cgi"
4
+ require "erb"
5
+ require "pathname"
6
+
7
+ module Slidict
8
+ module Cli
9
+ class Serve
10
+ DEFAULT_PUBLIC_DIR = "public"
11
+
12
+ def initialize(public_dir: DEFAULT_PUBLIC_DIR, output: $stdout)
13
+ @public_dir = File.expand_path(public_dir)
14
+ @output = output
15
+ end
16
+
17
+ def run(args = [])
18
+ app = build_app
19
+ original_argv = ARGV.dup
20
+ ARGV.replace(args)
21
+ @output.puts "Serving slides from #{@public_dir}"
22
+ app.run!
23
+ 0
24
+ ensure
25
+ ARGV.replace(original_argv) if original_argv
26
+ end
27
+
28
+ private
29
+
30
+ def build_app
31
+ require "sinatra/base"
32
+
33
+ public_dir = @public_dir
34
+
35
+ Class.new(Sinatra::Base) do
36
+ set :public_folder, public_dir
37
+ set :static, true
38
+ set :show_exceptions, false
39
+
40
+ get "/" do
41
+ @public_dir = public_dir
42
+ @entries = slide_entries(public_dir)
43
+ erb INDEX_TEMPLATE
44
+ end
45
+
46
+ helpers do
47
+ def slide_entries(public_dir)
48
+ return [] unless Dir.exist?(public_dir)
49
+
50
+ Dir.glob(File.join(public_dir, "**", "*")).filter_map do |path|
51
+ next unless File.file?(path)
52
+
53
+ relative = Pathname.new(path).relative_path_from(Pathname.new(public_dir)).to_s
54
+ next unless slide_file?(relative)
55
+
56
+ { title: title_for(relative), href: "/#{escape_path(relative)}", path: relative }
57
+ end.sort_by { |entry| entry[:path] }
58
+ end
59
+
60
+ def slide_file?(path)
61
+ File.extname(path).match?(/\A\.(?:adoc|md|markdown)\z/i)
62
+ end
63
+
64
+ def title_for(path)
65
+ dirname = File.dirname(path)
66
+ basename = File.basename(path, File.extname(path))
67
+ dirname == "." ? basename : dirname
68
+ end
69
+
70
+ def escape_path(path)
71
+ # CGI.escape turns spaces into "+", which Rack's static file
72
+ # routing does not unescape back to a literal space. Percent-encode
73
+ # each segment instead so hrefs match the file on disk.
74
+ path.split("/").map { |segment| ERB::Util.url_encode(segment) }.join("/")
75
+ end
76
+
77
+ def h(text)
78
+ CGI.escapeHTML(text.to_s)
79
+ end
80
+ end
81
+ end
82
+ end
83
+
84
+ INDEX_TEMPLATE = <<~ERB.freeze
85
+ <!doctype html>
86
+ <html lang="en">
87
+ <head>
88
+ <meta charset="utf-8">
89
+ <meta name="viewport" content="width=device-width, initial-scale=1">
90
+ <title>Slidict slides</title>
91
+ <style>
92
+ body { font-family: system-ui, sans-serif; margin: 2rem; }
93
+ li { margin: 0.5rem 0; }
94
+ code { color: #555; }
95
+ </style>
96
+ </head>
97
+ <body>
98
+ <h1>Slidict slides</h1>
99
+ <% if @entries.empty? %>
100
+ <p>No slides found in <code><%= h(@public_dir) %></code>.</p>
101
+ <% else %>
102
+ <ul>
103
+ <% @entries.each do |entry| %>
104
+ <li><a href="<%= entry[:href] %>"><%= h(entry[:title]) %></a> <code><%= h(entry[:path]) %></code></li>
105
+ <% end %>
106
+ </ul>
107
+ <% end %>
108
+ </body>
109
+ </html>
110
+ ERB
111
+ end
112
+ end
113
+ end
@@ -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