slidict 0.5.2 → 0.5.3

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d9219a4421ee2daa082652c0967bbc585fff8aadf8ba90becbee2d3293597252
4
- data.tar.gz: e1d4fa02a468b3b51d28681168151983cf89464d1dfb9e309d3c8b2a5e56dbee
3
+ metadata.gz: bc5350820094f49312526c30c254f437edd49374ac0202ba99067637a70dd331
4
+ data.tar.gz: 130980c85ff4a85a0189b95fddc825ea30eeae131e1549d83e80cee1ae5ba392
5
5
  SHA512:
6
- metadata.gz: b17f7f5c07ebb337c964e14a0884e2cd8502da2664cbe3d3f8553296596e3cbc40497c2b659fd494dd778effae1ce4c51b9b714266b6527fd2cbc9b233dd5fa9
7
- data.tar.gz: 273809d25f6adf3551113f9bd2d7a7d664d3fb58a8e8bac1634dff46e4c41d0c873925537012c1b8c3f7f3e30af18a12b3a497a65835d83c1c4eebc8941d30b5
6
+ metadata.gz: d27d844ad6ed53f6d860fe92e8614fad35e19caa10a9e5395b4eb6640c314a0224b0c32a5de612f13cababc8e438f61c1d6103d5a5c3a8133e2021eb67be6f73
7
+ data.tar.gz: 241380442ea402ed968bbd7829ec8ec49446f70d7f80852d75248a829d041b09d7a9c62d9ead1341d49e6dd5beff152340c7766f822bd064c29a1e0c407fb200
data/AGENTS.md CHANGED
@@ -10,7 +10,7 @@ OpenAI-compatible chat API.
10
10
 
11
11
  ## Setup
12
12
 
13
- - Ruby 3.1+
13
+ - Ruby 3.2.0+
14
14
  - Install dependencies: `bundle install`
15
15
 
16
16
  ## Development workflow
data/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  ## [Unreleased]
2
2
 
3
+ - Add `slidict init` to create a `.env` file (for `SLIDICT_LLM_*`, `SLIDICT_FRAMEWORK`,
4
+ and `SLIDICT_METHOD`) and add it to `.gitignore`. `.env` is loaded automatically and
5
+ fills in unset environment variables; CLI flags still take precedence.
6
+ - Add `SLIDICT_FRAMEWORK` and `SLIDICT_METHOD` environment variables as defaults for
7
+ `--framework` and `--method`.
8
+ - Add `--language` to generate slide titles and bullets in a language other than English,
9
+ and to translate the interactive prompt questions into that language via the LLM.
10
+
3
11
  ## [0.5.0] - 2026-07-01
4
12
 
5
13
  - Add data-driven presentation methods with SCQA, PREP, and Pyramid Principle templates.
data/README.md CHANGED
@@ -25,7 +25,7 @@ Unlike traditional slide generators, Slidict focuses on communication before sli
25
25
 
26
26
  ## Requirements
27
27
 
28
- - Ruby 3.1 or later
28
+ - Ruby 3.2.0 or later
29
29
 
30
30
  ## Usage
31
31
 
@@ -94,6 +94,16 @@ Asciidoctor Reveal.js -> public/001.adoc, public/002.adoc, ...
94
94
 
95
95
  ## Commands
96
96
 
97
+ ### `slidict init`
98
+
99
+ Creates a `.env` file for `SLIDICT_LLM_*`, `SLIDICT_FRAMEWORK`, and `SLIDICT_METHOD`
100
+ (see [Configuration](#configuration)) and adds `.env` to `.gitignore`. Does nothing to
101
+ an existing `.env` or an already-updated `.gitignore`.
102
+
103
+ ```bash
104
+ bin/slidict init
105
+ ```
106
+
97
107
  ### `slidict auth`
98
108
 
99
109
  Authenticates the CLI with your GitHub account via the device code flow and saves a
@@ -167,19 +177,55 @@ override it with `--format`. Run `bin/slidict lint -h` for the full list of opti
167
177
  ## Configuration
168
178
 
169
179
  Slidict generates slides with an LLM through any OpenAI Compatible API. Configure the
170
- target endpoint with environment variables or CLI flags (flags take precedence):
180
+ target endpoint with environment variables, a `.env` file, or CLI flags:
171
181
 
172
182
  | Environment variable | CLI flag | Default |
173
183
  | ------------------------ | ---------------- | -------------- |
174
184
  | `SLIDICT_LLM_BASE_URL` | `--llm-base-url` | _(none)_ |
175
185
  | `SLIDICT_LLM_API_KEY` | `--llm-api-key` | _(none)_ |
176
186
  | `SLIDICT_LLM_MODEL` | `--llm-model` | `gpt-4o-mini` |
187
+ | `SLIDICT_FRAMEWORK` | `--framework` | `slidev` |
188
+ | `SLIDICT_METHOD` | `--method` | _(none)_ |
189
+
190
+ Precedence is CLI flag > real environment variable > `.env` file > built-in default (a
191
+ `.env` value fills in only variables that aren't already set in the environment). Run
192
+ `slidict init` to create a `.env` file in the current directory (with these variables
193
+ commented out) and add `.env` to `.gitignore`:
194
+
195
+ ```bash
196
+ bin/slidict init
197
+ ```
198
+
199
+ ```text
200
+ Created .env
201
+ Added .env to .gitignore
202
+ ```
203
+
204
+ Then edit `.env` to set the values you want, for example:
205
+
206
+ ```bash
207
+ SLIDICT_LLM_BASE_URL=http://localhost:11434/v1
208
+ SLIDICT_LLM_API_KEY=ollama
209
+ SLIDICT_LLM_MODEL=llama3
210
+ SLIDICT_FRAMEWORK=marp
211
+ ```
212
+
213
+ Since `.env` is gitignored, it's a good place for secrets like API keys that
214
+ shouldn't be committed. A CLI flag always overrides both `.env` and the real
215
+ environment for that invocation.
177
216
 
178
217
  If no `llm-base-url` is configured, Slidict uses its built-in slide template and never
179
218
  calls an LLM. Once a `llm-base-url` is set, Slidict always calls that endpoint; if the
180
219
  request fails, Slidict reports the error and exits without writing a file (no fallback).
181
220
  You can force the template even when a base URL is configured with `--no-llm`.
182
221
 
222
+ Use `--language LANG` (e.g. `--language Japanese`) to have the LLM write the generated
223
+ slide titles and bullets in a language other than English. When an `llm-base-url` is
224
+ also configured, Slidict asks the LLM to translate any interactive questions it still
225
+ needs to ask (topic, duration, audience, goal) into that language too, so you're not
226
+ answering Japanese-bound slides in English prompts. This only affects LLM-generated
227
+ slides and questions; the built-in template stays in English.
228
+
183
229
  Examples:
184
230
 
185
231
  ```bash
@@ -6,18 +6,58 @@ require "pathname"
6
6
  module Slidict
7
7
  module Cli
8
8
  class App
9
- def initialize(input: $stdin, output: $stdout, renderer: Output::Renderer.new, auth_client: nil,
10
- credentials: nil, sleeper: Kernel, slides_command: nil, server: nil, lint_command: nil)
11
- @input = input
12
- @output = output
13
- @renderer = renderer
14
- @auth_client = auth_client
15
- @credentials = credentials
16
- @sleeper = sleeper
17
- @slides_command = slides_command
18
- @server = server
19
- @lint_command = lint_command
20
- end
9
+ include Options
10
+
11
+ # Keyed by the same symbols as the parsed CLI options (options[:topic],
12
+ # etc.), so a missing answer's question text can be looked up directly
13
+ # by `questions_for`.
14
+ QUESTIONS = {
15
+ topic: "What would you like to talk about?",
16
+ duration: "How long is the presentation?",
17
+ audience: "Who is the audience?",
18
+ goal: "What should the audience remember or do?"
19
+ }.freeze
20
+
21
+ options input: -> { $stdin },
22
+ output: -> { $stdout },
23
+ renderer: -> { Output::Renderer.new },
24
+ auth_client: -> { nil },
25
+ credentials: -> { nil },
26
+ sleeper: -> { Kernel },
27
+ slides_command: -> { nil },
28
+ server: -> { nil },
29
+ lint_command: -> { nil }
30
+
31
+ flag "--topic", arg: "TEXT", desc: "Presentation topic"
32
+ flag "--duration", arg: "TEXT", desc: 'Presentation length, for example "5 minutes"'
33
+ flag "--audience", arg: "TEXT", desc: "Target audience"
34
+ flag "--goal", arg: "TEXT", desc: "Desired audience takeaway or action"
35
+ flag "--framework", arg: "NAME",
36
+ desc: -> { "#{Output::Format.names.join(", ")} (default: slidev)\n(env: SLIDICT_FRAMEWORK)" }
37
+ flag "--method", arg: "ID", desc: "Presentation method, for example scqa, prep, or pyramid\n" \
38
+ "(env: SLIDICT_METHOD)"
39
+ flag "--language", arg: "LANG", desc: "Generate slide titles and bullets in the given language\n" \
40
+ "(e.g. Japanese); only affects LLM-generated slides"
41
+ flag "--filename", arg: "NAME", desc: "File name under public/ (default: next sequential file)"
42
+ flag "--llm-base-url", arg: "URL", desc: "OpenAI Compatible API base URL (env: SLIDICT_LLM_BASE_URL).\n" \
43
+ "When omitted, the built-in slide template is used instead."
44
+ flag "--llm-api-key", arg: "KEY", desc: "API key for the LLM endpoint (env: SLIDICT_LLM_API_KEY)"
45
+ # rubocop:disable Layout/HashAlignment -- source-only line wrap, desc stays a single displayed line
46
+ flag "--llm-model", arg: "NAME",
47
+ desc: "Model name to request (env: SLIDICT_LLM_MODEL); omit to list available models"
48
+ # rubocop:enable Layout/HashAlignment
49
+ flag "--no-llm", desc: "Skip the LLM call and use the built-in slide template"
50
+ flag "--publish", desc: "Publish the generated slides to slidict.io as a draft\n" \
51
+ "(requires `slidict auth`; creates a new slide, or edits\n" \
52
+ "an existing one when --slide-id is given)"
53
+ # rubocop:disable Layout/HashAlignment -- source-only line wrap, desc stays a single displayed line
54
+ flag "--slide-id", arg: "ID",
55
+ desc: "Edit this existing draft instead of creating a new one\n(implies --publish)"
56
+ # rubocop:enable Layout/HashAlignment
57
+ flag "--slide-title", arg: "TEXT", desc: "Title for the published slide (default: --topic)"
58
+ flag "--visibility", arg: "VIS", desc: "public, unlisted, or group_only (default: public)"
59
+ flag "-o", "--output", arg: "PATH", desc: "Output file (overrides --filename and the public/ default)"
60
+ flag "-h", "--help", desc: "Show this help"
21
61
 
22
62
  def run(argv = [])
23
63
  options = parse(argv)
@@ -28,28 +68,30 @@ module Slidict
28
68
  return lint(options[:args]) if options[:command] == "lint"
29
69
  return list_methods if options[:command] == "list-methods"
30
70
  return show_method(options[:args]) if options[:command] == "show-method"
71
+ return init if options[:command] == "init"
31
72
 
32
73
  config = build_config(options)
33
74
  return print_available_models(config) if config.llm_enabled? && config.model.nil?
34
75
 
35
76
  client = llm_client_for(config)
36
- return 1 if client && !verify_connection(client)
77
+ return FAILURE if client && !verify_connection(client)
37
78
 
79
+ questions = questions_for(client, options)
38
80
  deck = Deck.new(
39
- topic: ask("What would you like to talk about?", options[:topic]),
40
- duration: ask("How long is the presentation?", options[:duration]),
41
- audience: ask("Who is the audience?", options[:audience]),
42
- goal: ask("What should the audience remember or do?", options[:goal]),
81
+ topic: ask(questions[:topic], options[:topic]),
82
+ duration: ask(questions[:duration], options[:duration]),
83
+ audience: ask(questions[:audience], options[:audience]),
84
+ goal: ask(questions[:goal], options[:goal]),
43
85
  framework: options[:framework],
44
86
  presentation_method: options[:presentation_method]
45
87
  )
46
88
 
47
89
  if client
48
90
  begin
49
- slides = client.generate_slides(deck)
91
+ slides = client.generate_slides(deck, language: options[:language])
50
92
  rescue Llm::Client::Error => e
51
93
  @output.puts "Error: LLM request failed (#{e.message})"
52
- return 1
94
+ return FAILURE
53
95
  end
54
96
  deck = Deck.new(
55
97
  topic: deck.topic, duration: deck.duration, audience: deck.audience, goal: deck.goal,
@@ -65,18 +107,18 @@ module Slidict
65
107
 
66
108
  return publish_to_slidict(deck, content, options) if options[:publish] || options[:slide_id]
67
109
 
68
- 0
110
+ SUCCESS
69
111
  rescue ArgumentError => e
70
112
  @output.puts "Error: #{e.message}"
71
113
  @output.puts
72
114
  print_help
73
- 1
115
+ FAILURE
74
116
  end
75
117
 
76
118
  private
77
119
 
78
120
  def parse(argv)
79
- options = { framework: "slidev" }
121
+ options = { framework: ENV["SLIDICT_FRAMEWORK"] || "slidev", method: ENV["SLIDICT_METHOD"] }
80
122
  args = argv.dup
81
123
 
82
124
  args.shift if args.first == "new"
@@ -125,47 +167,16 @@ module Slidict
125
167
  return options
126
168
  end
127
169
 
128
- until args.empty?
129
- case (arg = args.shift)
130
- when "-h", "--help"
131
- options[:help] = true
132
- when "-o", "--output"
133
- options[:output] = fetch_value!(args, arg)
134
- when "--filename"
135
- options[:filename] = fetch_value!(args, arg)
136
- when "--topic"
137
- options[:topic] = fetch_value!(args, arg)
138
- when "--duration"
139
- options[:duration] = fetch_value!(args, arg)
140
- when "--audience"
141
- options[:audience] = fetch_value!(args, arg)
142
- when "--goal"
143
- options[:goal] = fetch_value!(args, arg)
144
- when "--framework"
145
- options[:framework] = fetch_value!(args, arg)
146
- when "--method"
147
- options[:method] = fetch_value!(args, arg)
148
- when "--llm-base-url"
149
- options[:llm_base_url] = fetch_value!(args, arg)
150
- when "--llm-api-key"
151
- options[:llm_api_key] = fetch_value!(args, arg)
152
- when "--llm-model"
153
- options[:llm_model] = fetch_value!(args, arg)
154
- when "--no-llm"
155
- options[:no_llm] = true
156
- when "--publish"
157
- options[:publish] = true
158
- when "--slide-id"
159
- options[:slide_id] = fetch_value!(args, arg)
160
- when "--slide-title"
161
- options[:slide_title] = fetch_value!(args, arg)
162
- when "--visibility"
163
- options[:visibility] = fetch_value!(args, arg)
164
- else
165
- raise ArgumentError, "unknown option #{arg}"
166
- end
170
+ if args.first == "init"
171
+ args.shift
172
+ raise ArgumentError, "init does not accept options" unless args.empty?
173
+
174
+ options[:command] = "init"
175
+ return options
167
176
  end
168
177
 
178
+ parse_flags!(args, options)
179
+
169
180
  options[:output] ||= output_path_for(options[:framework], options[:filename])
170
181
  options[:presentation_method] = method_for(options[:method])
171
182
  options
@@ -189,10 +200,10 @@ module Slidict
189
200
  @output.puts "Available models (specify one with --llm-model NAME or SLIDICT_LLM_MODEL=NAME):"
190
201
  models.each { |m| @output.puts " #{m}" }
191
202
  end
192
- 0
203
+ SUCCESS
193
204
  rescue Llm::Client::Error => e
194
205
  @output.puts "Error: LLM request failed (#{e.message})"
195
- 1
206
+ FAILURE
196
207
  end
197
208
 
198
209
  def llm_client_for(config)
@@ -228,7 +239,7 @@ module Slidict
228
239
  provider: token.fetch("provider", "github")
229
240
  )
230
241
  @output.puts "4. Saved CLI access token to #{path}"
231
- return 0
242
+ return SUCCESS
232
243
  rescue External::SlidictIo::Auth::Pending
233
244
  return login_expired if Time.now >= deadline
234
245
 
@@ -236,7 +247,7 @@ module Slidict
236
247
  end
237
248
  rescue External::SlidictIo::Auth::Error, KeyError => e
238
249
  @output.puts "Error: GitHub auth failed (#{e.message})"
239
- 1
250
+ FAILURE
240
251
  end
241
252
 
242
253
  def slides(args)
@@ -258,7 +269,7 @@ module Slidict
258
269
  id: method.id, name: method.name, category: method.category
259
270
  )
260
271
  end
261
- 0
272
+ SUCCESS
262
273
  end
263
274
 
264
275
  def show_method(args)
@@ -275,13 +286,53 @@ module Slidict
275
286
  method.slides.each_with_index { |slide, i| @output.puts " #{i + 1}. #{slide.title}: #{slide.role}" }
276
287
  @output.puts "Review checklist:"
277
288
  method.review_checklist.each { |item| @output.puts " - #{item}" }
278
- 0
289
+ SUCCESS
279
290
  end
280
291
 
281
292
  def method_for(id)
282
293
  id ? method_registry.fetch(id) : nil
283
294
  end
284
295
 
296
+ ENV_TEMPLATE = <<~ENV
297
+ # Slidict configuration. CLI flags always take precedence over these
298
+ # values; unset ones fall back to the built-in defaults. This file is
299
+ # ignored by git (see .gitignore) so it's safe to put secrets here.
300
+
301
+ # OpenAI Compatible API endpoint (required to enable LLM-generated slides).
302
+ # SLIDICT_LLM_BASE_URL=https://api.openai.com/v1
303
+ # SLIDICT_LLM_API_KEY=sk-...
304
+ # SLIDICT_LLM_MODEL=gpt-4o-mini
305
+
306
+ # Default --framework when it is not given on the command line.
307
+ # SLIDICT_FRAMEWORK=slidev
308
+
309
+ # Default --method when it is not given on the command line.
310
+ # SLIDICT_METHOD=scqa
311
+ ENV
312
+
313
+ def init
314
+ env_created = write_env_file
315
+ @output.puts(env_created ? "Created .env" : ".env already exists, leaving it unchanged")
316
+
317
+ gitignore_updated = ensure_env_gitignored
318
+ @output.puts "Added .env to .gitignore" if gitignore_updated
319
+ SUCCESS
320
+ end
321
+
322
+ def write_env_file
323
+ return false if File.exist?(".env")
324
+
325
+ File.write(".env", ENV_TEMPLATE)
326
+ true
327
+ end
328
+
329
+ def ensure_env_gitignored
330
+ return false if File.exist?(".gitignore") && File.readlines(".gitignore", chomp: true).include?(".env")
331
+
332
+ File.open(".gitignore", "a") { |f| f.puts(".env") }
333
+ true
334
+ end
335
+
285
336
  def method_registry
286
337
  @method_registry ||= PresentationMethodRegistry.new
287
338
  end
@@ -314,7 +365,7 @@ module Slidict
314
365
 
315
366
  def login_expired
316
367
  @output.puts "Error: GitHub auth timed out. Run `slidict auth` and try again."
317
- 1
368
+ FAILURE
318
369
  end
319
370
 
320
371
  def fetch_value!(args, option)
@@ -332,9 +383,29 @@ module Slidict
332
383
  @input.gets&.chomp.to_s
333
384
  end
334
385
 
335
- def print_help
336
- @output.puts <<~HELP
386
+ # Translates only the questions that will actually be asked (those
387
+ # whose option wasn't already given on the command line). Falls back to
388
+ # the English questions if there's no client, no --language, nothing
389
+ # left to ask, or a translation call fails -- this is a nicety, not
390
+ # something worth aborting slide generation over.
391
+ def questions_for(client, options)
392
+ return QUESTIONS unless client && options[:language]
393
+
394
+ missing = QUESTIONS.select { |key, _| options[key].to_s.strip.empty? }
395
+ return QUESTIONS if missing.empty?
396
+
397
+ translated = missing.transform_values { |text| client.translate_text(text, options[:language]) }
398
+ QUESTIONS.merge(translated)
399
+ rescue Llm::Client::Error => e
400
+ @output.puts "Warning: could not translate questions into #{options[:language]} " \
401
+ "(#{e.message}); asking in English."
402
+ QUESTIONS
403
+ end
404
+
405
+ usage do
406
+ <<~USAGE
337
407
  Usage: slidict [new] [options]
408
+ Usage: slidict init
338
409
  Usage: slidict auth
339
410
  Usage: slidict slides <list|show|create|edit> [options]
340
411
  Usage: slidict serve [sinatra options]
@@ -345,6 +416,7 @@ module Slidict
345
416
  Generate presentation source files from a short conversation.
346
417
 
347
418
  Commands:
419
+ init Create a .env file for SLIDICT_LLM_* etc. and add it to .gitignore
348
420
  auth Authenticate the CLI with GitHub and save a CLI access token
349
421
  slides Manage your slides on slidict.io (run `slidict slides -h` for details)
350
422
  serve Serve slide files from ./public with Sinatra
@@ -354,29 +426,8 @@ module Slidict
354
426
  show-method Show details for one presentation method
355
427
 
356
428
  Options:
357
- --topic TEXT Presentation topic
358
- --duration TEXT Presentation length, for example "5 minutes"
359
- --audience TEXT Target audience
360
- --goal TEXT Desired audience takeaway or action
361
- --framework NAME #{Output::Format.names.join(", ")} (default: slidev)
362
- --method ID Presentation method, for example scqa, prep, or pyramid
363
- --filename NAME File name under public/ (default: next sequential file)
364
- --llm-base-url URL OpenAI Compatible API base URL (env: SLIDICT_LLM_BASE_URL).
365
- When omitted, the built-in slide template is used instead.
366
- --llm-api-key KEY API key for the LLM endpoint (env: SLIDICT_LLM_API_KEY)
367
- --llm-model NAME Model name to request (env: SLIDICT_LLM_MODEL); omit to list available models
368
- --no-llm Skip the LLM call and use the built-in slide template
369
- --publish Publish the generated slides to slidict.io as a draft
370
- (requires `slidict auth`; creates a new slide, or edits
371
- an existing one when --slide-id is given)
372
- --slide-id ID Edit this existing draft instead of creating a new one
373
- (implies --publish)
374
- --slide-title TEXT Title for the published slide (default: --topic)
375
- --visibility VIS public, unlisted, or group_only (default: public)
376
- -o, --output PATH Output file (overrides --filename and the public/ default)
377
- -h, --help Show this help
378
- HELP
379
- 0
429
+ #{flags_help}
430
+ USAGE
380
431
  end
381
432
 
382
433
  def output_path_for(framework, filename)
@@ -8,11 +8,21 @@ module Slidict
8
8
  class Lint
9
9
  ASCIIDOC_EXTENSIONS = %w[.adoc .asciidoc].freeze
10
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
11
+ include Options
12
+
13
+ options output: Options::MISSING,
14
+ linter_factory: -> { method(:default_linter) },
15
+ renderer: -> { Slidict::Lint::Renderer.new }
16
+
17
+ flag "--format", arg: "FORMAT", desc: "markdown or asciidoc (default: auto-detected from extension)"
18
+ flag "--llm-base-url", arg: "URL", desc: "OpenAI Compatible API base URL (env: SLIDICT_LLM_BASE_URL)"
19
+ flag "--llm-api-key", arg: "KEY", desc: "API key for the LLM endpoint (env: SLIDICT_LLM_API_KEY)"
20
+ # rubocop:disable Layout/HashAlignment -- source-only line wrap, desc stays a single displayed line
21
+ flag "--llm-model", arg: "NAME",
22
+ desc: "Model name to request (env: SLIDICT_LLM_MODEL); omit to list available models"
23
+ # rubocop:enable Layout/HashAlignment
24
+ flag "--translate", arg: "LANG", desc: "Translate findings into the given language (e.g. Japanese)"
25
+ flag "-h", "--help", desc: "Show this help"
16
26
 
17
27
  def run(argv)
18
28
  options = parse(argv)
@@ -44,18 +54,18 @@ module Slidict
44
54
  print_error(error)
45
55
  @output.puts
46
56
  print_help
47
- 1
57
+ FAILURE
48
58
  end
49
59
 
50
60
  def print_error(error)
51
61
  @output.puts "Error: #{error.message}"
52
- 1
62
+ FAILURE
53
63
  end
54
64
 
55
65
  def parse(argv)
56
66
  args = argv.dup
57
67
  options = { path: extract_path!(args) }
58
- parse_options!(args, options)
68
+ parse_flags!(args, options)
59
69
  options
60
70
  end
61
71
 
@@ -63,20 +73,6 @@ module Slidict
63
73
  args.shift unless args.first.to_s.start_with?("-")
64
74
  end
65
75
 
66
- def parse_options!(args, options)
67
- until args.empty?
68
- case (arg = args.shift)
69
- when "-h", "--help" then options[:help] = true
70
- when "--format" then options[:format] = fetch_value!(args, arg)
71
- when "--llm-base-url" then options[:llm_base_url] = fetch_value!(args, arg)
72
- when "--llm-api-key" then options[:llm_api_key] = fetch_value!(args, arg)
73
- when "--llm-model" then options[:llm_model] = fetch_value!(args, arg)
74
- when "--translate" then options[:translate] = fetch_value!(args, arg)
75
- else raise ArgumentError, "unknown option #{arg}"
76
- end
77
- end
78
- end
79
-
80
76
  def fetch_value!(args, option)
81
77
  value = args.shift
82
78
  raise ArgumentError, "#{option} requires a value" if value.nil? || value.start_with?("-")
@@ -105,12 +101,12 @@ module Slidict
105
101
 
106
102
  def print_findings(findings)
107
103
  @output.puts(findings.empty? ? "No issues found." : @renderer.render(findings))
108
- 0
104
+ SUCCESS
109
105
  end
110
106
 
111
107
  def file_not_found(path)
112
108
  @output.puts "Error: file not found: #{path}"
113
- 1
109
+ FAILURE
114
110
  end
115
111
 
116
112
  def print_available_models(config)
@@ -122,28 +118,22 @@ module Slidict
122
118
  @output.puts "Available models (specify one with --llm-model NAME or SLIDICT_LLM_MODEL=NAME):"
123
119
  models.each { |m| @output.puts " #{m}" }
124
120
  end
125
- 0
121
+ SUCCESS
126
122
  rescue Llm::Client::Error => e
127
123
  print_error(e)
128
124
  end
129
125
 
130
126
  def llm_required
131
127
  @output.puts "Error: lint requires an LLM endpoint (--llm-base-url or SLIDICT_LLM_BASE_URL)"
132
- 1
128
+ FAILURE
133
129
  end
134
130
 
135
- def print_help
136
- @output.puts <<~HELP
131
+ usage do
132
+ <<~USAGE
137
133
  Usage: slidict lint <file> [options]
138
134
  Diagnoses whether a slide deck's structure will land with its audience.
139
- --format FORMAT markdown or asciidoc (default: auto-detected from extension)
140
- --llm-base-url URL OpenAI Compatible API base URL (env: SLIDICT_LLM_BASE_URL)
141
- --llm-api-key KEY API key for the LLM endpoint (env: SLIDICT_LLM_API_KEY)
142
- --llm-model NAME Model name to request (env: SLIDICT_LLM_MODEL); omit to list available models
143
- --translate LANG Translate findings into the given language (e.g. Japanese)
144
- -h, --help Show this help
145
- HELP
146
- 0
135
+ #{flags_help}
136
+ USAGE
147
137
  end
148
138
  end
149
139
  end
@@ -0,0 +1,148 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Slidict
4
+ module Cli
5
+ # Concern for CLI classes with several constructor dependencies. Declaring
6
+ # `options name: -> { default }, ...` once generates both the attr_reader
7
+ # and the `initialize` assignment, instead of repeating each dependency in
8
+ # the method signature and again as `@name = name` in the body.
9
+ #
10
+ # Defaults are instance_exec'd lazily (only when the keyword is omitted),
11
+ # matching plain keyword-argument defaults, and may reference other
12
+ # options or instance methods (e.g. `-> { method(:default_linter) }`).
13
+ # Classes that need to post-process a value (e.g. expanding a path) can
14
+ # still override `initialize`, call `super`, and adjust the ivar.
15
+ #
16
+ # Also provides a `flag`/`parse_flags!`/`flags_help` DSL: declaring a
17
+ # `-x`/`--xxx` switch once (with its arg placeholder and description)
18
+ # drives both argv parsing and the `--help` listing, so the two can't
19
+ # drift apart. `usage` then only needs to hold the surrounding usage text
20
+ # (Usage:/Commands: lines), interpolating `flags_help` for the option list.
21
+ module Options
22
+ MISSING = Object.new.freeze
23
+
24
+ # Process exit codes (see bin/slidict, which does `exit App.new.run(ARGV)`),
25
+ # named so command methods don't scatter bare 0/1 literals as return values.
26
+ SUCCESS = 0
27
+ FAILURE = 1
28
+
29
+ def self.included(base)
30
+ base.extend(ClassMethods)
31
+ end
32
+
33
+ # One `-x`/`--xxx` declaration: how to parse it (arg placeholder,
34
+ # coercion) and how to describe it in `--help` output.
35
+ Flag = Struct.new(:switches, :key, :arg, :coerce, :desc, keyword_init: true) do
36
+ def boolean?
37
+ arg.nil?
38
+ end
39
+
40
+ def short
41
+ switches.find { |s| !s.start_with?("--") }
42
+ end
43
+
44
+ def long
45
+ switches.find { |s| s.start_with?("--") }
46
+ end
47
+
48
+ # Left column of the help listing, e.g. "-o, --output PATH" or, for a
49
+ # long-only flag, " --topic TEXT" -- the leading 4 spaces keep the
50
+ # "--name" text column-aligned either way.
51
+ def name_column
52
+ column = "#{short ? "#{short}, " : " "}#{long}"
53
+ arg ? "#{column} #{arg}" : column
54
+ end
55
+ end
56
+
57
+ # Class-level DSL (`options name: -> { default }, ...`) mixed into any
58
+ # class that includes Options.
59
+ module ClassMethods
60
+ # Defines the assignment in an anonymous module (rather than directly
61
+ # on the including class) so a class that needs to post-process a
62
+ # value can still define its own `initialize`, call `super`, and have
63
+ # it reach this one instead of silently overwriting it.
64
+ def options(**defaults)
65
+ attr_reader(*defaults.keys)
66
+
67
+ include(Module.new do
68
+ define_method(:initialize) do |**overrides|
69
+ defaults.each do |name, default|
70
+ instance_variable_set(:"@#{name}", resolve_option(name, default, overrides))
71
+ end
72
+ end
73
+ end)
74
+ end
75
+
76
+ # Declares a flag. `group` lets a class with several argv shapes
77
+ # (e.g. Slides' `list` vs. `create`/`edit`) keep separate flag sets;
78
+ # `parse_flags!`/`flags_help` default to :default. `desc` may be a
79
+ # Proc (instance_exec'd lazily, like an `options` default) for text
80
+ # that depends on another file having loaded by call time rather
81
+ # than by require time (e.g. `Output::Format.names`).
82
+ def flag(*switches, desc:, arg: nil, coerce: nil, group: :default)
83
+ key = switches.last.sub(/\A-+/, "").tr("-", "_").to_sym
84
+ flag_groups[group] << Flag.new(switches: switches, key: key, arg: arg, coerce: coerce, desc: desc)
85
+ end
86
+
87
+ def flag_groups
88
+ @flag_groups ||= Hash.new { |h, k| h[k] = [] }
89
+ end
90
+
91
+ def flags(group = :default)
92
+ flag_groups[group]
93
+ end
94
+
95
+ # Declares the `-h`/`--help` usage text, generating a private
96
+ # `print_help` that writes it to `@output` and returns SUCCESS,
97
+ # instead of every command spelling that out. Takes a block for the
98
+ # same lazy-evaluation reason as `flag`'s `desc:`.
99
+ def usage(&text)
100
+ private(define_method(:print_help) do
101
+ @output.puts instance_exec(&text)
102
+ SUCCESS
103
+ end)
104
+ end
105
+ end
106
+
107
+ private
108
+
109
+ def resolve_option(name, default, overrides)
110
+ return overrides[name] if overrides.key?(name)
111
+ raise ArgumentError, "missing keyword: :#{name}" if default.equal?(MISSING)
112
+
113
+ instance_exec(&default)
114
+ end
115
+
116
+ # Consumes `args` against `self.class.flags(group)`: booleans are set to
117
+ # true, valued flags read their value via the including class's own
118
+ # `fetch_value!` (whose rules on e.g. a leading "-" differ between
119
+ # classes), then any `coerce` is applied.
120
+ def parse_flags!(args, options, group = :default)
121
+ until args.empty?
122
+ arg = args.shift
123
+ flag = self.class.flags(group).find { |f| f.switches.include?(arg) }
124
+ raise ArgumentError, "unknown option #{arg}" unless flag
125
+
126
+ value = flag.boolean? || fetch_value!(args, arg)
127
+ value = instance_exec(value, &flag.coerce) if flag.coerce
128
+ options[flag.key] = value
129
+ end
130
+ end
131
+
132
+ # Renders `self.class.flags(group)` as an aligned --help listing.
133
+ def flags_help(group = :default)
134
+ defs = self.class.flags(group)
135
+ width = defs.map { |f| f.name_column.length }.max
136
+ defs.map { |f| flag_doc_lines(f, width) }.join("\n")
137
+ end
138
+
139
+ def flag_doc_lines(flag, width)
140
+ desc = flag.desc.respond_to?(:call) ? instance_exec(&flag.desc) : flag.desc
141
+ first, *rest = desc.lines(chomp: true)
142
+ lines = ["#{flag.name_column.ljust(width)} #{first}"]
143
+ rest.each { |line| lines << "#{" " * (width + 2)}#{line}" }
144
+ lines.join("\n")
145
+ end
146
+ end
147
+ end
148
+ end
@@ -9,9 +9,14 @@ module Slidict
9
9
  class Serve
10
10
  DEFAULT_PUBLIC_DIR = "public"
11
11
 
12
- def initialize(public_dir: DEFAULT_PUBLIC_DIR, output: $stdout)
13
- @public_dir = File.expand_path(public_dir)
14
- @output = output
12
+ include Options
13
+
14
+ options public_dir: -> { DEFAULT_PUBLIC_DIR },
15
+ output: -> { $stdout }
16
+
17
+ def initialize(**)
18
+ super
19
+ @public_dir = File.expand_path(@public_dir)
15
20
  end
16
21
 
17
22
  def run(args = [])
@@ -20,7 +25,7 @@ module Slidict
20
25
  ARGV.replace(args)
21
26
  @output.puts "Serving slides from #{@public_dir}"
22
27
  app.run!
23
- 0
28
+ SUCCESS
24
29
  ensure
25
30
  ARGV.replace(original_argv) if original_argv
26
31
  end
@@ -4,12 +4,29 @@ module Slidict
4
4
  module Cli
5
5
  # Implements the `slidict slides <list|show|create|edit>` subcommands.
6
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
7
+ include Options
8
+
9
+ options output: Options::MISSING,
10
+ credentials: -> { External::SlidictIo::Credentials.new },
11
+ client: -> { nil },
12
+ reauthenticate: -> { nil }
13
+
14
+ # rubocop:disable Layout/HashAlignment -- source-only line wraps below, kept short rather than column-aligned
15
+ flag "--page", arg: "N",
16
+ coerce: ->(v) { Integer(v) }, desc: "Page number (20 slides per page)", group: :list
17
+ # rubocop:enable Layout/HashAlignment
18
+ flag "-h", "--help", desc: "Show this help", group: :list
19
+
20
+ flag "--title", arg: "TEXT", desc: "Slide title", group: :body_options
21
+ flag "--body", arg: "TEXT", desc: "Slide body text", group: :body_options
22
+ # rubocop:disable Layout/HashAlignment -- source-only line wraps below, kept short rather than column-aligned
23
+ flag "--file", arg: "PATH", desc: "Read the slide body from a file (instead of --body)",
24
+ group: :body_options
25
+ flag "--body-format", arg: "FORMAT", desc: "asciidoc or markdown (default: auto-detected from body)",
26
+ group: :body_options
27
+ # rubocop:enable Layout/HashAlignment
28
+ flag "--visibility", arg: "VIS", desc: "public, unlisted, or group_only (default: public)", group: :body_options
29
+ flag "-h", "--help", desc: "Show this help", group: :body_options
13
30
 
14
31
  def run(argv)
15
32
  options = parse(argv)
@@ -20,7 +37,7 @@ module Slidict
20
37
  @output.puts "Error: #{e.message}"
21
38
  @output.puts
22
39
  print_help
23
- 1
40
+ FAILURE
24
41
  end
25
42
 
26
43
  # Creates or edits a draft directly, bypassing argv parsing (and its
@@ -49,13 +66,7 @@ module Slidict
49
66
 
50
67
  def parse_list(args)
51
68
  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
69
+ parse_flags!(args, options, :list)
59
70
  options
60
71
  end
61
72
 
@@ -83,17 +94,7 @@ module Slidict
83
94
  end
84
95
 
85
96
  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
+ parse_flags!(args, options, :body_options)
97
98
  raise ArgumentError, "specify only one of --body or --file" if options[:body] && options[:file]
98
99
  end
99
100
 
@@ -111,17 +112,17 @@ module Slidict
111
112
  def list(options)
112
113
  with_reauth_retry do
113
114
  print_slide_list(client.list(page: options[:page]))
114
- 0
115
+ SUCCESS
115
116
  end
116
117
  end
117
118
 
118
119
  def show(options)
119
120
  with_reauth_retry do
120
121
  print_slide_detail(client.show(options[:id]))
121
- 0
122
+ SUCCESS
122
123
  rescue External::SlidictIo::Client::NotFound
123
124
  @output.puts "Error: slide not found"
124
- 1
125
+ FAILURE
125
126
  end
126
127
  end
127
128
 
@@ -147,13 +148,13 @@ module Slidict
147
148
  slide = yield
148
149
  @output.puts "#{verb} slide ##{slide["id"]} (draft)"
149
150
  print_slide_detail(slide)
150
- 0
151
+ SUCCESS
151
152
  rescue External::SlidictIo::Client::NotFound
152
153
  @output.puts "Error: slide not found"
153
- 1
154
+ FAILURE
154
155
  rescue External::SlidictIo::Client::NotEditable
155
156
  @output.puts "Error: this slide is already published. Edit it from the Web UI instead."
156
- 1
157
+ FAILURE
157
158
  rescue External::SlidictIo::Client::RateLimited
158
159
  print_rate_limited
159
160
  rescue External::SlidictIo::Client::Unprocessable => e
@@ -230,22 +231,22 @@ module Slidict
230
231
 
231
232
  def print_rate_limited
232
233
  @output.puts "Error: rate limited. Create/edit is limited to once per minute. Wait and try again."
233
- 1
234
+ FAILURE
234
235
  end
235
236
 
236
237
  def print_unprocessable(error)
237
238
  @output.puts "Error: #{error.message}"
238
239
  error.errors.each { |message| @output.puts " - #{message}" }
239
- 1
240
+ FAILURE
240
241
  end
241
242
 
242
243
  def print_client_error(error)
243
244
  @output.puts "Error: #{error.message}"
244
- 1
245
+ FAILURE
245
246
  end
246
247
 
247
- def print_help
248
- @output.puts <<~HELP
248
+ usage do
249
+ <<~USAGE
249
250
  Usage: slidict slides <command> [options]
250
251
 
251
252
  Commands:
@@ -255,16 +256,10 @@ module Slidict
255
256
  edit <id> [options] Edit an existing draft slide
256
257
 
257
258
  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
259
+ #{flags_help(:body_options)}
264
260
 
265
261
  Note: slides are always created/edited as drafts. Publish from the Web UI.
266
- HELP
267
- 0
262
+ USAGE
268
263
  end
269
264
  end
270
265
  end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Slidict
4
+ # Loads KEY=VALUE pairs from a .env file (if present) into ENV, so
5
+ # SLIDICT_LLM_BASE_URL, SLIDICT_FRAMEWORK, etc. can be set once per project
6
+ # instead of on every invocation. Only keys not already present in ENV are
7
+ # populated -- a real environment variable always wins over .env, and a CLI
8
+ # flag (read afterwards, in Cli::App#parse) wins over both. See `slidict
9
+ # init` for generating a starter file.
10
+ module Env
11
+ LINE = /\A(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)=(.*)\z/
12
+
13
+ def self.load!(path = ".env", env = ENV)
14
+ return unless File.exist?(path)
15
+
16
+ File.foreach(path) do |line|
17
+ key, value = parse_line(line)
18
+ env[key] = value if key && !env.key?(key)
19
+ end
20
+ end
21
+
22
+ def self.parse_line(line)
23
+ line = line.strip
24
+ return [nil, nil] if line.empty? || line.start_with?("#")
25
+
26
+ match = LINE.match(line)
27
+ return [nil, nil] unless match
28
+
29
+ [match[1], unquote(match[2].strip)]
30
+ end
31
+ private_class_method :parse_line
32
+
33
+ # A quoted value keeps everything up to its closing quote verbatim (so a
34
+ # "#" inside it isn't mistaken for a comment); an unquoted value has any
35
+ # trailing "# comment" stripped.
36
+ def self.unquote(value)
37
+ if value.start_with?('"', "'")
38
+ quote = value[0]
39
+ closing = value.index(quote, 1)
40
+ return closing ? value[1...closing] : value[1..]
41
+ end
42
+
43
+ value.sub(/\s+#.*\z/, "")
44
+ end
45
+ private_class_method :unquote
46
+ end
47
+ end
@@ -35,8 +35,8 @@ module Slidict
35
35
  raise Error, "could not parse models response: #{e.message}"
36
36
  end
37
37
 
38
- def generate_slides(deck)
39
- content = chat_completion(prompt_for(deck))
38
+ def generate_slides(deck, language: nil)
39
+ content = chat_completion(prompt_for(deck, language: language))
40
40
  slides_from(content)
41
41
  end
42
42
 
@@ -48,6 +48,16 @@ module Slidict
48
48
  findings_from(content)
49
49
  end
50
50
 
51
+ # Translates a single line of text into language. Unlike
52
+ # generate_slides/lint_slides, the response is a plain string rather
53
+ # than parsed JSON: asking a (often small, local) model to translate
54
+ # one line is a task it can follow reliably, whereas asking it to also
55
+ # return a JSON array of a specific length is exactly the kind of
56
+ # structured-output instruction weaker models tend to get wrong.
57
+ def translate_text(text, language)
58
+ strip_quotes(chat_completion(translate_prompt_for(text, language)).strip)
59
+ end
60
+
51
61
  private
52
62
 
53
63
  def get_models_response
@@ -57,7 +67,7 @@ module Slidict
57
67
  perform_request(uri, request)
58
68
  end
59
69
 
60
- def prompt_for(deck)
70
+ def prompt_for(deck, language: nil)
61
71
  <<~PROMPT
62
72
  You are an assistant that designs presentation slide outlines.
63
73
  Topic: #{deck.topic}
@@ -69,11 +79,17 @@ module Slidict
69
79
  Return one slide for each required slide role when a presentation method is
70
80
  provided; otherwise return exactly 5 slides. Each item must be an object with
71
81
  a "title" string and a "bullets" array of 2-4 short strings.
72
- Respond with the JSON array only: no commentary, no markdown code fences,
73
- and no reasoning or thinking content before or after it.
82
+ #{language_instruction_for(language)}Respond with the JSON array only: no commentary, no markdown code
83
+ fences, and no reasoning or thinking content before or after it.
74
84
  PROMPT
75
85
  end
76
86
 
87
+ def language_instruction_for(language)
88
+ return "" unless language
89
+
90
+ "Write the \"title\" and \"bullets\" text in #{language}.\n"
91
+ end
92
+
77
93
  def method_prompt_for(deck)
78
94
  method = deck.presentation_method
79
95
  return "" unless method
@@ -132,6 +148,23 @@ module Slidict
132
148
  translate ? "#{prompt}\nTranslate only the \"message\" field of each finding into #{translate}. Keep \"slide\" as an integer and \"severity\" as exactly \"warning\" or \"info\" — do not translate those values." : prompt
133
149
  end
134
150
 
151
+ def translate_prompt_for(text, language)
152
+ <<~PROMPT
153
+ Translate the following text into #{language}. Respond with only the
154
+ translation: no commentary, no surrounding quotation marks, no
155
+ markdown formatting, and no reasoning or thinking content before or
156
+ after it.
157
+
158
+ #{text}
159
+ PROMPT
160
+ end
161
+
162
+ # Small/local models often ignore the "no quotation marks" instruction
163
+ # above and wrap the translation anyway.
164
+ def strip_quotes(text)
165
+ text.sub(/\A["'“”‘’「」]+/, "").sub(/["'“”‘’「」]+\z/, "")
166
+ end
167
+
135
168
  def findings_from(content)
136
169
  parsed = JSON.parse(extract_json_array(content))
137
170
  raise Error, "expected a JSON array of findings" unless parsed.is_a?(Array)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Slidict
4
- VERSION = "0.5.2"
4
+ VERSION = "0.5.3"
5
5
  end
data/lib/slidict.rb CHANGED
@@ -2,12 +2,14 @@
2
2
 
3
3
  require "time"
4
4
 
5
+ require_relative "slidict/cli/options"
5
6
  require_relative "slidict/cli/app"
6
7
  require_relative "slidict/cli/lint"
7
8
  require_relative "slidict/cli/serve"
8
9
  require_relative "slidict/cli/slides"
9
10
  require_relative "slidict/config"
10
11
  require_relative "slidict/deck"
12
+ require_relative "slidict/env"
11
13
  require_relative "slidict/external/slidict_io/auth"
12
14
  require_relative "slidict/external/slidict_io/client"
13
15
  require_relative "slidict/external/slidict_io/credentials"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: slidict
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.2
4
+ version: 0.5.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Yusuke Abe
@@ -83,10 +83,12 @@ files:
83
83
  - lib/slidict.rb
84
84
  - lib/slidict/cli/app.rb
85
85
  - lib/slidict/cli/lint.rb
86
+ - lib/slidict/cli/options.rb
86
87
  - lib/slidict/cli/serve.rb
87
88
  - lib/slidict/cli/slides.rb
88
89
  - lib/slidict/config.rb
89
90
  - lib/slidict/deck.rb
91
+ - lib/slidict/env.rb
90
92
  - lib/slidict/external/slidict_io/auth.rb
91
93
  - lib/slidict/external/slidict_io/client.rb
92
94
  - lib/slidict/external/slidict_io/credentials.rb