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.
metadata CHANGED
@@ -1,14 +1,56 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: slidict
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Yusuke Abe
8
8
  bindir: exe
9
9
  cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
11
- dependencies: []
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: puma
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '6.0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '6.0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: rackup
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '2.0'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '2.0'
40
+ - !ruby/object:Gem::Dependency
41
+ name: sinatra
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: '4.0'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: '4.0'
12
54
  description: Slidict is a Ruby CLI for turning rough ideas into presentation-ready
13
55
  Markdown slides.
14
56
  email:
@@ -31,15 +73,22 @@ files:
31
73
  - README.md
32
74
  - Rakefile
33
75
  - lib/slidict.rb
34
- - lib/slidict/auth_client.rb
35
- - lib/slidict/cli.rb
76
+ - lib/slidict/cli/app.rb
77
+ - lib/slidict/cli/lint.rb
78
+ - lib/slidict/cli/serve.rb
79
+ - lib/slidict/cli/slides.rb
36
80
  - lib/slidict/config.rb
37
- - lib/slidict/credentials.rb
38
81
  - lib/slidict/deck.rb
39
- - lib/slidict/llm_client.rb
40
- - lib/slidict/markdown_renderer.rb
41
- - lib/slidict/slides_client.rb
42
- - lib/slidict/slides_command.rb
82
+ - lib/slidict/external/slidict_io/auth.rb
83
+ - lib/slidict/external/slidict_io/client.rb
84
+ - lib/slidict/external/slidict_io/credentials.rb
85
+ - lib/slidict/lint/finding.rb
86
+ - lib/slidict/lint/linter.rb
87
+ - lib/slidict/lint/renderer.rb
88
+ - lib/slidict/lint/slide_parser.rb
89
+ - lib/slidict/llm/client.rb
90
+ - lib/slidict/output/format.rb
91
+ - lib/slidict/output/renderer.rb
43
92
  - lib/slidict/version.rb
44
93
  homepage: https://labs.slidict.io/slidict/
45
94
  licenses:
@@ -1,75 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "json"
4
- require "net/http"
5
- require "uri"
6
-
7
- module Slidict
8
- class AuthClient
9
- Error = Class.new(StandardError)
10
- Pending = Class.new(StandardError)
11
-
12
- DEFAULT_BASE_URL = "https://slidict.io"
13
-
14
- attr_reader :base_url
15
-
16
- def initialize(base_url: ENV.fetch("SLIDICT_AUTH_BASE_URL", DEFAULT_BASE_URL))
17
- @base_url = base_url
18
- end
19
-
20
- def request_device_code
21
- response = post_json("/api/cli/device/code", { provider: "github" })
22
- {
23
- device_code: fetch!(response, "device_code"),
24
- user_code: fetch!(response, "user_code"),
25
- verification_uri: response["verification_uri"] || "#{base_url}/cli/activate",
26
- interval: response.fetch("interval", 5).to_i,
27
- expires_in: response.fetch("expires_in", 600).to_i
28
- }
29
- end
30
-
31
- def poll_token(device_code:)
32
- response = post_json(
33
- "/api/cli/device/token",
34
- { device_code: device_code, grant_type: "urn:ietf:params:oauth:grant-type:device_code" },
35
- raise_on_http_error: false
36
- )
37
- return response if response["access_token"]
38
-
39
- error = response["error"].to_s
40
- raise Pending if %w[authorization_pending slow_down].include?(error)
41
-
42
- raise Error, response["error_description"] || error unless error.empty?
43
-
44
- raise Error, "token response did not include access_token"
45
- end
46
-
47
- private
48
-
49
- def post_json(path, payload, raise_on_http_error: true)
50
- uri = URI.join(base_url, path)
51
- request = Net::HTTP::Post.new(uri)
52
- request["Accept"] = "application/json"
53
- request["Content-Type"] = "application/json"
54
- request.body = JSON.generate(payload)
55
-
56
- response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == "https") do |http|
57
- http.request(request)
58
- end
59
- body = response.body.to_s.empty? ? {} : JSON.parse(response.body)
60
- return body if response.is_a?(Net::HTTPSuccess) || !raise_on_http_error
61
-
62
- raise Error, body["error_description"] || body["error"] || "HTTP #{response.code}"
63
- rescue JSON::ParserError => e
64
- raise Error, "invalid JSON response: #{e.message}"
65
- rescue SystemCallError, Timeout::Error => e
66
- raise Error, e.message
67
- end
68
-
69
- def fetch!(hash, key)
70
- hash.fetch(key)
71
- rescue KeyError
72
- raise Error, "device code response did not include #{key}"
73
- end
74
- end
75
- end
data/lib/slidict/cli.rb DELETED
@@ -1,266 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Slidict
4
- class CLI
5
- DEFAULT_OUTPUT = "slides.md"
6
- DEFAULT_OUTPUT_BY_FRAMEWORK = {
7
- "slidev" => "slides.md",
8
- "marp" => "slides.md",
9
- "asciidoctor-revealjs" => "slides.adoc"
10
- }.freeze
11
-
12
- def initialize(input: $stdin, output: $stdout, renderer: MarkdownRenderer.new, auth_client: nil,
13
- credentials: nil, sleeper: Kernel, slides_command: nil)
14
- @input = input
15
- @output = output
16
- @renderer = renderer
17
- @auth_client = auth_client
18
- @credentials = credentials
19
- @sleeper = sleeper
20
- @slides_command = slides_command
21
- end
22
-
23
- def run(argv = [])
24
- options = parse(argv)
25
- return print_help if options[:help]
26
- return auth if options[:command] == "auth"
27
- return slides(options[:args]) if options[:command] == "slides"
28
-
29
- config = build_config(options)
30
- client = llm_client_for(config)
31
- return 1 if client && !verify_connection(client)
32
-
33
- deck = Deck.new(
34
- topic: ask("What would you like to talk about?", options[:topic]),
35
- duration: ask("How long is the presentation?", options[:duration]),
36
- audience: ask("Who is the audience?", options[:audience]),
37
- goal: ask("What should the audience remember or do?", options[:goal]),
38
- framework: options[:framework]
39
- )
40
-
41
- if client
42
- begin
43
- slides = client.generate_slides(deck)
44
- rescue LLMClient::Error => e
45
- @output.puts "Error: LLM request failed (#{e.message})"
46
- return 1
47
- end
48
- deck = Deck.new(
49
- topic: deck.topic, duration: deck.duration, audience: deck.audience, goal: deck.goal,
50
- framework: deck.framework, slides: slides
51
- )
52
- end
53
-
54
- path = options[:output]
55
- content = @renderer.render(deck)
56
- File.write(path, content)
57
- @output.puts "Created #{path}"
58
-
59
- return publish_to_slidict(deck, content, options) if options[:publish] || options[:slide_id]
60
-
61
- 0
62
- rescue ArgumentError => e
63
- @output.puts "Error: #{e.message}"
64
- @output.puts
65
- print_help
66
- 1
67
- end
68
-
69
- private
70
-
71
- def parse(argv)
72
- options = { framework: "slidev" }
73
- args = argv.dup
74
-
75
- if args.first == "auth"
76
- args.shift
77
- raise ArgumentError, "auth does not accept options" unless args.empty?
78
-
79
- options[:command] = "auth"
80
- return options
81
- end
82
-
83
- if args.first == "slides"
84
- args.shift
85
- options[:command] = "slides"
86
- options[:args] = args
87
- return options
88
- end
89
-
90
- until args.empty?
91
- case (arg = args.shift)
92
- when "-h", "--help"
93
- options[:help] = true
94
- when "-o", "--output"
95
- options[:output] = fetch_value!(args, arg)
96
- when "--topic"
97
- options[:topic] = fetch_value!(args, arg)
98
- when "--duration"
99
- options[:duration] = fetch_value!(args, arg)
100
- when "--audience"
101
- options[:audience] = fetch_value!(args, arg)
102
- when "--goal"
103
- options[:goal] = fetch_value!(args, arg)
104
- when "--framework"
105
- options[:framework] = fetch_value!(args, arg)
106
- when "--llm-base-url"
107
- options[:llm_base_url] = fetch_value!(args, arg)
108
- when "--llm-api-key"
109
- options[:llm_api_key] = fetch_value!(args, arg)
110
- when "--llm-model"
111
- options[:llm_model] = fetch_value!(args, arg)
112
- when "--no-llm"
113
- options[:no_llm] = true
114
- when "--publish"
115
- options[:publish] = true
116
- when "--slide-id"
117
- options[:slide_id] = fetch_value!(args, arg)
118
- when "--slide-title"
119
- options[:slide_title] = fetch_value!(args, arg)
120
- when "--visibility"
121
- options[:visibility] = fetch_value!(args, arg)
122
- else
123
- raise ArgumentError, "unknown option #{arg}"
124
- end
125
- end
126
-
127
- options[:output] ||= default_output_for(options[:framework])
128
- options
129
- end
130
-
131
- def build_config(options)
132
- Config.from_env.merge(
133
- base_url: options[:llm_base_url],
134
- api_key: options[:llm_api_key],
135
- model: options[:llm_model],
136
- enabled: options[:no_llm] ? false : nil
137
- )
138
- end
139
-
140
- def llm_client_for(config)
141
- return nil unless config.llm_enabled?
142
-
143
- LLMClient.new(base_url: config.base_url, api_key: config.api_key, model: config.model)
144
- end
145
-
146
- def verify_connection(client)
147
- client.verify_connection!
148
- true
149
- rescue LLMClient::Error => e
150
- @output.puts "Error: LLM request failed (#{e.message})"
151
- false
152
- end
153
-
154
- def auth
155
- client = @auth_client || AuthClient.new
156
- credentials = @credentials || Credentials.new
157
-
158
- device = client.request_device_code
159
- @output.puts "1. Open #{device[:verification_uri]} in your browser"
160
- @output.puts "2. Enter code: #{device[:user_code]}"
161
- @output.puts "3. Log in with GitHub"
162
- @output.puts "Waiting for GitHub authentication..."
163
-
164
- deadline = Time.now + device[:expires_in]
165
- loop do
166
- token = client.poll_token(device_code: device[:device_code])
167
- path = credentials.write_cli_token!(
168
- access_token: token.fetch("access_token"),
169
- token_type: token.fetch("token_type", "Bearer"),
170
- provider: token.fetch("provider", "github")
171
- )
172
- @output.puts "4. Saved CLI access token to #{path}"
173
- return 0
174
- rescue AuthClient::Pending
175
- return login_expired if Time.now >= deadline
176
-
177
- @sleeper.sleep(device[:interval])
178
- end
179
- rescue AuthClient::Error, KeyError => e
180
- @output.puts "Error: GitHub auth failed (#{e.message})"
181
- 1
182
- end
183
-
184
- def slides(args)
185
- slides_command.run(args)
186
- end
187
-
188
- def publish_to_slidict(deck, content, options)
189
- slides_command.publish(
190
- id: options[:slide_id],
191
- title: options[:slide_title] || deck.topic,
192
- body: content,
193
- body_format: body_format_for(deck.framework),
194
- visibility: options[:visibility]
195
- )
196
- end
197
-
198
- def slides_command
199
- @slides_command ||= SlidesCommand.new(output: @output, credentials: @credentials, reauthenticate: method(:auth))
200
- end
201
-
202
- def body_format_for(framework)
203
- framework.to_s.downcase == "asciidoctor-revealjs" ? "asciidoc" : "markdown"
204
- end
205
-
206
- def login_expired
207
- @output.puts "Error: GitHub auth timed out. Run `slidict auth` and try again."
208
- 1
209
- end
210
-
211
- def fetch_value!(args, option)
212
- value = args.shift
213
- raise ArgumentError, "#{option} requires a value" if value.nil? || value.start_with?("-")
214
-
215
- value
216
- end
217
-
218
- def ask(question, provided)
219
- return provided unless provided.nil? || provided.strip.empty?
220
-
221
- @output.puts question
222
- @output.print "> "
223
- @input.gets&.chomp.to_s
224
- end
225
-
226
- def print_help
227
- @output.puts <<~HELP
228
- Usage: slidict [options]
229
- Usage: slidict auth
230
- Usage: slidict slides <list|show|create|edit> [options]
231
-
232
- Generate presentation source files from a short conversation.
233
-
234
- Commands:
235
- auth Authenticate the CLI with GitHub and save a CLI access token
236
- slides Manage your slides on slidict.io (run `slidict slides -h` for details)
237
-
238
- Options:
239
- --topic TEXT Presentation topic
240
- --duration TEXT Presentation length, for example "5 minutes"
241
- --audience TEXT Target audience
242
- --goal TEXT Desired audience takeaway or action
243
- --framework NAME slidev, marp, or asciidoctor-revealjs (default: slidev)
244
- --llm-base-url URL OpenAI Compatible API base URL (env: SLIDICT_LLM_BASE_URL).
245
- When omitted, the built-in slide template is used instead.
246
- --llm-api-key KEY API key for the LLM endpoint (env: SLIDICT_LLM_API_KEY)
247
- --llm-model NAME Model name to request (env: SLIDICT_LLM_MODEL, default: gpt-4o-mini)
248
- --no-llm Skip the LLM call and use the built-in slide template
249
- --publish Publish the generated slides to slidict.io as a draft
250
- (requires `slidict auth`; creates a new slide, or edits
251
- an existing one when --slide-id is given)
252
- --slide-id ID Edit this existing draft instead of creating a new one
253
- (implies --publish)
254
- --slide-title TEXT Title for the published slide (default: --topic)
255
- --visibility VIS public, unlisted, or group_only (default: public)
256
- -o, --output PATH Output file (default depends on --framework)
257
- -h, --help Show this help
258
- HELP
259
- 0
260
- end
261
-
262
- def default_output_for(framework)
263
- DEFAULT_OUTPUT_BY_FRAMEWORK.fetch(framework.to_s.downcase, DEFAULT_OUTPUT)
264
- end
265
- end
266
- end
@@ -1,39 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "fileutils"
4
- require "json"
5
-
6
- module Slidict
7
- class Credentials
8
- DEFAULT_PATH = File.expand_path("~/.config/slidict/credentials.json")
9
-
10
- def initialize(path: DEFAULT_PATH)
11
- @path = path
12
- end
13
-
14
- def write_cli_token!(access_token:, token_type: "Bearer", provider: "github")
15
- FileUtils.mkdir_p(File.dirname(@path), mode: 0o700)
16
- json = JSON.pretty_generate(
17
- "access_token" => access_token,
18
- "token_type" => token_type,
19
- "provider" => provider,
20
- "kind" => "cli_access_token"
21
- )
22
- File.write(@path, "#{json}\n", mode: "w", perm: 0o600)
23
- File.chmod(0o600, @path)
24
- @path
25
- end
26
-
27
- # Returns { access_token:, token_type: } or nil if no CLI access token is saved.
28
- def read_cli_token
29
- return nil unless File.exist?(@path)
30
-
31
- data = JSON.parse(File.read(@path))
32
- return nil unless data["kind"] == "cli_access_token" && data["access_token"]
33
-
34
- { access_token: data["access_token"], token_type: data.fetch("token_type", "Bearer") }
35
- rescue JSON::ParserError
36
- nil
37
- end
38
- end
39
- end
@@ -1,144 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "net/http"
4
- require "uri"
5
- require "json"
6
-
7
- module Slidict
8
- # Talks to any OpenAI Compatible API (OpenAI, Ollama, LM Studio, vLLM, etc.)
9
- # via the standard /chat/completions endpoint. Configure the target with
10
- # Slidict::Config (base_url, api_key, model).
11
- class LLMClient
12
- class Error < StandardError; end
13
-
14
- def initialize(base_url:, api_key:, model:)
15
- @base_url = base_url
16
- @api_key = api_key
17
- @model = model
18
- end
19
-
20
- # Checks that the endpoint is reachable before the (slower, more
21
- # expensive) chat completion request is made. Raises Error on failure.
22
- def verify_connection!
23
- uri = endpoint_uri("models")
24
- request = Net::HTTP::Get.new(uri)
25
- request["Authorization"] = "Bearer #{@api_key}"
26
- response = perform_request(uri, request)
27
-
28
- raise Error, "#{response.code} #{response.message}" unless response.is_a?(Net::HTTPSuccess)
29
- end
30
-
31
- def generate_slides(deck)
32
- content = chat_completion(prompt_for(deck))
33
- slides_from(content)
34
- end
35
-
36
- private
37
-
38
- def prompt_for(deck)
39
- <<~PROMPT
40
- You are an assistant that designs presentation slide outlines.
41
- Topic: #{deck.topic}
42
- Duration: #{deck.duration}
43
- Audience: #{deck.audience}
44
- Goal: #{deck.goal}
45
-
46
- Return exactly 5 slides as a JSON array. Each item must be an object with
47
- a "title" string and a "bullets" array of 2-4 short strings.
48
- Respond with the JSON array only: no commentary, no markdown code fences,
49
- and no reasoning or thinking content before or after it.
50
- PROMPT
51
- end
52
-
53
- def chat_completion(prompt)
54
- response = JSON.parse(post_chat_completion(prompt))
55
- content = response.dig("choices", 0, "message", "content")
56
- raise Error, "empty response from model" if content.to_s.strip.empty?
57
-
58
- content
59
- rescue JSON::ParserError => e
60
- raise Error, "could not parse model response: #{e.message}"
61
- end
62
-
63
- def post_chat_completion(prompt)
64
- uri = endpoint_uri("chat/completions")
65
- request = build_request(uri, prompt)
66
- response = perform_request(uri, request)
67
-
68
- raise Error, "#{response.code} #{response.message}" unless response.is_a?(Net::HTTPSuccess)
69
-
70
- response.body
71
- end
72
-
73
- def perform_request(uri, request)
74
- Net::HTTP.start(uri.host, uri.port,
75
- use_ssl: uri.scheme == "https",
76
- open_timeout: 5,
77
- read_timeout: 30,
78
- write_timeout: 30) do |http|
79
- http.request(request)
80
- end
81
- rescue StandardError => e
82
- raise Error, e.message
83
- end
84
-
85
- def endpoint_uri(path)
86
- base = @base_url.to_s.sub(%r{/+\z}, "")
87
- URI.join("#{base}/", path)
88
- end
89
-
90
- def build_request(uri, prompt)
91
- request = Net::HTTP::Post.new(uri)
92
- request["Content-Type"] = "application/json"
93
- request["Authorization"] = "Bearer #{@api_key}"
94
- request.body = JSON.generate(
95
- model: @model,
96
- messages: [{ role: "user", content: prompt }],
97
- temperature: 0.7
98
- )
99
- request
100
- end
101
-
102
- def slides_from(content)
103
- parsed = JSON.parse(extract_json_array(content))
104
- raise Error, "expected a JSON array of slides" unless parsed.is_a?(Array)
105
-
106
- parsed.map do |item|
107
- Slide.new(title: item.fetch("title"), bullets: Array(item.fetch("bullets")))
108
- end
109
- rescue JSON::ParserError, KeyError => e
110
- raise Error, "could not parse model response: #{e.message}"
111
- end
112
-
113
- # Some models (especially reasoning models served through LM Studio or
114
- # Ollama) prepend or append thinking/reasoning text around the JSON
115
- # answer instead of returning it verbatim, so the array is extracted from
116
- # within the raw content rather than parsed as-is.
117
- def extract_json_array(content)
118
- content.enum_for(:scan, /\[/).each do
119
- start = Regexp.last_match.begin(0)
120
- candidate = json_array_from(content, start)
121
- return candidate if candidate
122
- end
123
-
124
- raise Error, "no JSON array found in model response"
125
- end
126
-
127
- def json_array_from(content, start)
128
- finish = start
129
- while (finish = content.index("]", finish))
130
- candidate = content[start..finish]
131
- return candidate if parses_to_array?(candidate)
132
-
133
- finish += 1
134
- end
135
- nil
136
- end
137
-
138
- def parses_to_array?(candidate)
139
- JSON.parse(candidate).is_a?(Array)
140
- rescue JSON::ParserError
141
- false
142
- end
143
- end
144
- end
@@ -1,39 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Slidict
4
- class MarkdownRenderer
5
- FRONTMATTER_BY_FRAMEWORK = {
6
- "slidev" => "theme: default\nclass: text-center",
7
- "marp" => "marp: true\ntheme: default"
8
- }.freeze
9
-
10
- def render(deck)
11
- return render_asciidoctor_revealjs(deck) if deck.framework == "asciidoctor-revealjs"
12
-
13
- [frontmatter(deck.framework), deck.slides.map { |slide| render_slide(slide) }.join("\n---\n\n")].join("\n")
14
- end
15
-
16
- private
17
-
18
- def frontmatter(framework)
19
- body = FRONTMATTER_BY_FRAMEWORK.fetch(framework, FRONTMATTER_BY_FRAMEWORK["slidev"])
20
- "---\n#{body}\ngenerated: #{Time.now.utc.iso8601}\n---\n"
21
- end
22
-
23
- def render_slide(slide)
24
- lines = ["# #{slide.title}", ""]
25
- lines.concat(slide.bullets.map { |bullet| "- #{bullet}" })
26
- lines.join("\n")
27
- end
28
-
29
- def render_asciidoctor_revealjs(deck)
30
- lines = ["= #{deck.topic}", ":revealjs_theme: white", ":slidict_generated: #{Time.now.utc.iso8601}", ""]
31
- lines.concat(deck.slides.flat_map { |slide| render_asciidoctor_slide(slide) })
32
- lines.join("\n")
33
- end
34
-
35
- def render_asciidoctor_slide(slide)
36
- ["== #{slide.title}", "", *slide.bullets.map { |bullet| "* #{bullet}" }, ""]
37
- end
38
- end
39
- end