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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: cc62a4a9def18173cd4fa3c36a07b8113cbd952f3ecef69f25d31bdd8a0c2c29
4
- data.tar.gz: b559163e5add314725911941fece8829b47cd79f801369a4735a5b62fb6aadfb
3
+ metadata.gz: a9f6b8851999b92437addc0186d6a047c1c8317145940987514df479e02fabc2
4
+ data.tar.gz: fb42f714400e77cbc97931d105cef01a8b54776c0ce67db152ce6ce475136caf
5
5
  SHA512:
6
- metadata.gz: 7482973532b58361216b54dc6a45e81bee4bc39e65e9a26958c37c271cdcb97f5c915649ee26d88973f4e34f26a9a7eb2b56a0ad7224488bd87a739087201d25
7
- data.tar.gz: 946075256cf69b70511aae347f2d6a15164f87aeb4bd0678ac329af8a367773c515aec5a09bc4cab52a42b475e4aff7ec26995cbb7fce8f191ee3c12accea668
6
+ metadata.gz: 88ad47ca34c0e2d7bcf99f118905ba3b7b7f8df571aa4e118080552659564bc2bb7d79d007c3c67a9dc4ebd291e415e6cf423f93299f013485856737e54f7e77
7
+ data.tar.gz: 781cd459d6c82e62a29bbc8702bea3c7f9d4125d78341e2b13723608ded41e9131af456b87dbfc61525dbc7ebab91da9729fc4465e3b852c0298a96f1e277223
@@ -5,36 +5,19 @@ on:
5
5
  branches:
6
6
  - main
7
7
 
8
+ permissions:
9
+ contents: write
10
+ pull-requests: read
11
+
8
12
  jobs:
9
13
  changelog:
10
14
  runs-on: ubuntu-latest
11
15
 
12
16
  steps:
13
- - name: Checkout Code
14
- uses: actions/checkout@v3
15
- with:
16
- fetch-depth: 0
17
-
18
- - name: Extract version
19
- id: version
20
- run: |
21
- version=$(grep -Eo "[0-9]+\.[0-9]+\.[0-9]+" lib/slidict/version.rb | head -n 1)
22
- echo "version=v$version" >> $GITHUB_ENV
23
- echo "VERSION_TAG=v$version" >> $GITHUB_ENV
24
-
25
- - name: Check if version file changed
26
- id: version_file
27
- run: |
28
- if git diff --name-only "${{ github.event.before }}" "${{ github.sha }}" | grep -qx "lib/slidict/version.rb"; then
29
- echo "changed=true" >> "$GITHUB_OUTPUT"
30
- else
31
- echo "changed=false" >> "$GITHUB_OUTPUT"
32
- fi
33
-
34
- - name: Release with release-drafter
17
+ - name: Update draft release notes
35
18
  uses: release-drafter/release-drafter@v5
36
19
  with:
37
20
  config-name: release-drafter.yml
38
- publish: ${{ steps.version_file.outputs.changed == 'true' }}
21
+ publish: false
39
22
  env:
40
23
  GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -1,31 +1,51 @@
1
1
  name: Ruby Gem
2
2
 
3
3
  on:
4
- push:
5
- branches:
6
- - main
7
- paths:
8
- - lib/slidict/version.rb
4
+ workflow_run:
5
+ workflows:
6
+ - Changelog
7
+ types:
8
+ - completed
9
9
 
10
10
  jobs:
11
11
  build:
12
12
  name: Build + Publish
13
13
  runs-on: ubuntu-latest
14
+ if: github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.head_branch == 'main'
14
15
  permissions:
15
- contents: read
16
+ contents: write
16
17
  packages: write
17
18
 
18
19
  steps:
19
20
  - uses: actions/checkout@v3
20
21
  with:
22
+ ref: ${{ github.event.workflow_run.head_sha }}
21
23
  fetch-depth: 0
22
24
 
25
+ - name: Check if version file changed
26
+ id: version_file
27
+ run: |
28
+ if git diff --name-only HEAD~1 HEAD | grep -qx "lib/slidict/version.rb"; then
29
+ echo "changed=true" >> "$GITHUB_OUTPUT"
30
+ else
31
+ echo "changed=false" >> "$GITHUB_OUTPUT"
32
+ fi
33
+
34
+ - name: Extract version
35
+ if: steps.version_file.outputs.changed == 'true'
36
+ id: version
37
+ run: |
38
+ version=$(grep -Eo "[0-9]+\.[0-9]+\.[0-9]+" lib/slidict/version.rb | head -n 1)
39
+ echo "tag=v$version" >> "$GITHUB_OUTPUT"
40
+
23
41
  - name: Set up Ruby 3.4
42
+ if: steps.version_file.outputs.changed == 'true'
24
43
  uses: ruby/setup-ruby@v1
25
44
  with:
26
45
  ruby-version: 3.4.3
27
46
 
28
47
  - name: Publish to GPR
48
+ if: steps.version_file.outputs.changed == 'true'
29
49
  run: |
30
50
  mkdir -p $HOME/.gem
31
51
  touch $HOME/.gem/credentials
@@ -38,6 +58,7 @@ jobs:
38
58
  OWNER: ${{ github.repository_owner }}
39
59
 
40
60
  - name: Publish to RubyGems
61
+ if: steps.version_file.outputs.changed == 'true'
41
62
  run: |
42
63
  mkdir -p $HOME/.gem
43
64
  touch $HOME/.gem/credentials
@@ -47,3 +68,14 @@ jobs:
47
68
  gem push *.gem
48
69
  env:
49
70
  GEM_HOST_API_KEY: "${{ secrets.RUBYGEMS_AUTH_TOKEN }}"
71
+
72
+ - name: Publish release notes
73
+ if: steps.version_file.outputs.changed == 'true'
74
+ uses: release-drafter/release-drafter@v5
75
+ with:
76
+ config-name: release-drafter.yml
77
+ publish: true
78
+ tag: ${{ steps.version.outputs.tag }}
79
+ name: ${{ steps.version.outputs.tag }}
80
+ env:
81
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
data/AGENTS.md CHANGED
@@ -21,8 +21,8 @@ OpenAI-compatible chat API.
21
21
 
22
22
  Run both tests and lint before considering a change complete.
23
23
 
24
- When you add or change a CLI command (or its options), update the "Commands" section
25
- of `README.md` to document it.
24
+ When you add or change a feature (a CLI command, its options, or any other
25
+ user-facing behavior), update `README.md` to document it.
26
26
 
27
27
  ## Commit conventions
28
28
 
data/README.md CHANGED
@@ -11,7 +11,9 @@ Unlike traditional slide generators, Slidict focuses on communication before sli
11
11
  - Interactive CLI conversation
12
12
  - Generate slides for Slidev, Marp, Asciidoctor Reveal.js, and other OSS presentation frameworks
13
13
  - Local-first MVP implemented in Ruby
14
+ - Built-in Sinatra server for browsing generated slides from `public/`
14
15
  - OpenAI Compatible API support, so you can point Slidict at OpenAI, Ollama, LM Studio, vLLM, or any other server implementing the same `/chat/completions` endpoint
16
+ - Presentation structure linter: checks whether a Markdown/Asciidoc deck's *structure* (not its looks) will land with an audience
15
17
 
16
18
  ## Requirements
17
19
 
@@ -28,7 +30,7 @@ bin/slidict
28
30
  Slidict asks a few questions and generates presentation source files. For example, this creates a Marp Markdown deck:
29
31
 
30
32
  ```bash
31
- $ bin/slidict --framework marp --output slides.md
33
+ $ bin/slidict --framework marp
32
34
 
33
35
  What would you like to talk about?
34
36
  > PDF Difference Monitoring Service
@@ -38,7 +40,7 @@ Who is the audience?
38
40
  > Engineering managers
39
41
  What should the audience remember or do?
40
42
  > Approve an MVP pilot
41
- Created slides.md
43
+ Created public/001.md
42
44
  ```
43
45
 
44
46
  You can also provide answers non-interactively:
@@ -69,12 +71,17 @@ bin/slidict --topic "PDF Difference Monitoring Service" --duration "5 minutes" \
69
71
 
70
72
  ## Output files
71
73
 
72
- Choose the framework and output path that match the presentation tool you want to use. If you omit `--output`, Slidict chooses a framework-specific default:
74
+ Choose the framework and output path that match the presentation tool you want to use. If you omit `--output`, Slidict writes under `public/` with the next sequential file name. Use `--filename` to choose the relative file name under `public/`; Slidict appends the framework extension when the name has no extension.
75
+
76
+ ```bash
77
+ # Choose a served file name under public/
78
+ bin/slidict --filename product-demo/slides --topic "Product Demo"
79
+ ```
73
80
 
74
81
  ```text
75
- Slidev -> slides.md
76
- Marp -> slides.md
77
- Asciidoctor Reveal.js -> slides.adoc
82
+ Slidev -> public/001.md, public/002.md, ...
83
+ Marp -> public/001.md, public/002.md, ...
84
+ Asciidoctor Reveal.js -> public/001.adoc, public/002.adoc, ...
78
85
  ```
79
86
 
80
87
  ## Commands
@@ -107,9 +114,48 @@ bin/slidict slides edit <id> [--title TEXT] [--body TEXT | --file PATH] [--body-
107
114
 
108
115
  Run `bin/slidict slides -h` for the full list of options.
109
116
 
117
+ ### `slidict serve`
118
+
119
+ Serve generated slide files from the local `public/` directory with Sinatra. The top
120
+ page lists Markdown and Asciidoc slide files below `public/`, so you can organize
121
+ decks in subdirectories such as `public/product-demo/slides.md`. Any arguments
122
+ after `serve` are passed through to Sinatra.
123
+
124
+ ```bash
125
+ bin/slidict serve -p 4567 -o 0.0.0.0
126
+ ```
127
+
110
128
  `bin/slidict --publish` and `--slide-id` (see [Usage](#usage)) wrap this same `create`/`edit`
111
129
  behavior so you can save the slides you just generated straight to slidict.io.
112
130
 
131
+ ### `slidict lint`
132
+
133
+ Diagnoses whether a Markdown or Asciidoc slide deck has a structure an audience can
134
+ actually follow -- not whether it looks nice. Diagnosis only; it does not rewrite the
135
+ file. Requires an LLM endpoint (see [Configuration](#configuration)).
136
+
137
+ ```bash
138
+ bin/slidict lint talk.md --llm-base-url http://localhost:11434/v1 --llm-api-key ollama --llm-model llama3
139
+ ```
140
+
141
+ ```text
142
+ [warning] Slide 3: this slide's main point is unclear
143
+ [warning] Slide 5: "MCP" is used without first explaining it
144
+ [info] Slide 8: the closing slide is weak; consider adding a one-sentence takeaway for the audience
145
+ ```
146
+
147
+ It checks six things across the whole deck:
148
+
149
+ 1. Is the audience clear?
150
+ 2. Can the overall point be stated in one sentence?
151
+ 3. Does the deck flow background → problem → solution → result/learning?
152
+ 4. Is any single slide overloaded with information?
153
+ 5. Is jargon used without first explaining it?
154
+ 6. Does the closing slide give the audience a concrete takeaway?
155
+
156
+ The deck format (`markdown` or `asciidoc`) is auto-detected from the file extension;
157
+ override it with `--format`. Run `bin/slidict lint -h` for the full list of options.
158
+
113
159
  ## Configuration
114
160
 
115
161
  Slidict generates slides with an LLM through any OpenAI Compatible API. Configure the
@@ -174,6 +220,7 @@ We optimize for communication, not decoration.
174
220
  - [x] Interactive CLI
175
221
  - [x] Slide generation
176
222
  - [x] OpenAI Compatible API support (configurable base URL, so Ollama, LM Studio, and other compatible servers work out of the box)
223
+ - [x] Presentation structure linter (`slidict lint`)
177
224
 
178
225
  ## License
179
226
 
@@ -0,0 +1,336 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "pathname"
5
+
6
+ module Slidict
7
+ module Cli
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
21
+
22
+ def run(argv = [])
23
+ options = parse(argv)
24
+ return print_help if options[:help]
25
+ return auth if options[:command] == "auth"
26
+ return slides(options[:args]) if options[:command] == "slides"
27
+ return serve(options[:args]) if options[:command] == "serve"
28
+ return lint(options[:args]) if options[:command] == "lint"
29
+
30
+ config = build_config(options)
31
+ client = llm_client_for(config)
32
+ return 1 if client && !verify_connection(client)
33
+
34
+ deck = Deck.new(
35
+ topic: ask("What would you like to talk about?", options[:topic]),
36
+ duration: ask("How long is the presentation?", options[:duration]),
37
+ audience: ask("Who is the audience?", options[:audience]),
38
+ goal: ask("What should the audience remember or do?", options[:goal]),
39
+ framework: options[:framework]
40
+ )
41
+
42
+ if client
43
+ begin
44
+ slides = client.generate_slides(deck)
45
+ rescue Llm::Client::Error => e
46
+ @output.puts "Error: LLM request failed (#{e.message})"
47
+ return 1
48
+ end
49
+ deck = Deck.new(
50
+ topic: deck.topic, duration: deck.duration, audience: deck.audience, goal: deck.goal,
51
+ framework: deck.framework, slides: slides
52
+ )
53
+ end
54
+
55
+ path = options[:output]
56
+ content = @renderer.render(deck)
57
+ FileUtils.mkdir_p(File.dirname(path))
58
+ File.write(path, content)
59
+ @output.puts "Created #{path}"
60
+
61
+ return publish_to_slidict(deck, content, options) if options[:publish] || options[:slide_id]
62
+
63
+ 0
64
+ rescue ArgumentError => e
65
+ @output.puts "Error: #{e.message}"
66
+ @output.puts
67
+ print_help
68
+ 1
69
+ end
70
+
71
+ private
72
+
73
+ def parse(argv)
74
+ options = { framework: "slidev" }
75
+ args = argv.dup
76
+
77
+ if args.first == "auth"
78
+ args.shift
79
+ raise ArgumentError, "auth does not accept options" unless args.empty?
80
+
81
+ options[:command] = "auth"
82
+ return options
83
+ end
84
+
85
+ if args.first == "slides"
86
+ args.shift
87
+ options[:command] = "slides"
88
+ options[:args] = args
89
+ return options
90
+ end
91
+
92
+ if args.first == "serve"
93
+ args.shift
94
+ options[:command] = "serve"
95
+ options[:args] = args
96
+ return options
97
+ end
98
+
99
+ if args.first == "lint"
100
+ args.shift
101
+ options[:command] = "lint"
102
+ options[:args] = args
103
+ return options
104
+ end
105
+
106
+ until args.empty?
107
+ case (arg = args.shift)
108
+ when "-h", "--help"
109
+ options[:help] = true
110
+ when "-o", "--output"
111
+ options[:output] = fetch_value!(args, arg)
112
+ when "--filename"
113
+ options[:filename] = fetch_value!(args, arg)
114
+ when "--topic"
115
+ options[:topic] = fetch_value!(args, arg)
116
+ when "--duration"
117
+ options[:duration] = fetch_value!(args, arg)
118
+ when "--audience"
119
+ options[:audience] = fetch_value!(args, arg)
120
+ when "--goal"
121
+ options[:goal] = fetch_value!(args, arg)
122
+ when "--framework"
123
+ options[:framework] = fetch_value!(args, arg)
124
+ when "--llm-base-url"
125
+ options[:llm_base_url] = fetch_value!(args, arg)
126
+ when "--llm-api-key"
127
+ options[:llm_api_key] = fetch_value!(args, arg)
128
+ when "--llm-model"
129
+ options[:llm_model] = fetch_value!(args, arg)
130
+ when "--no-llm"
131
+ options[:no_llm] = true
132
+ when "--publish"
133
+ options[:publish] = true
134
+ when "--slide-id"
135
+ options[:slide_id] = fetch_value!(args, arg)
136
+ when "--slide-title"
137
+ options[:slide_title] = fetch_value!(args, arg)
138
+ when "--visibility"
139
+ options[:visibility] = fetch_value!(args, arg)
140
+ else
141
+ raise ArgumentError, "unknown option #{arg}"
142
+ end
143
+ end
144
+
145
+ options[:output] ||= output_path_for(options[:framework], options[:filename])
146
+ options
147
+ end
148
+
149
+ def build_config(options)
150
+ Config.from_env.merge(
151
+ base_url: options[:llm_base_url],
152
+ api_key: options[:llm_api_key],
153
+ model: options[:llm_model],
154
+ enabled: options[:no_llm] ? false : nil
155
+ )
156
+ end
157
+
158
+ def llm_client_for(config)
159
+ return nil unless config.llm_enabled?
160
+
161
+ Llm::Client.new(base_url: config.base_url, api_key: config.api_key, model: config.model)
162
+ end
163
+
164
+ def verify_connection(client)
165
+ client.verify_connection!
166
+ true
167
+ rescue Llm::Client::Error => e
168
+ @output.puts "Error: LLM request failed (#{e.message})"
169
+ false
170
+ end
171
+
172
+ def auth
173
+ client = @auth_client || External::SlidictIo::Auth.new
174
+ credentials = @credentials || External::SlidictIo::Credentials.new
175
+
176
+ device = client.request_device_code
177
+ @output.puts "1. Open #{device[:verification_uri]} in your browser"
178
+ @output.puts "2. Enter code: #{device[:user_code]}"
179
+ @output.puts "3. Log in with GitHub"
180
+ @output.puts "Waiting for GitHub authentication..."
181
+
182
+ deadline = Time.now + device[:expires_in]
183
+ loop do
184
+ token = client.poll_token(device_code: device[:device_code])
185
+ path = credentials.write_cli_token!(
186
+ access_token: token.fetch("access_token"),
187
+ token_type: token.fetch("token_type", "Bearer"),
188
+ provider: token.fetch("provider", "github")
189
+ )
190
+ @output.puts "4. Saved CLI access token to #{path}"
191
+ return 0
192
+ rescue External::SlidictIo::Auth::Pending
193
+ return login_expired if Time.now >= deadline
194
+
195
+ @sleeper.sleep(device[:interval])
196
+ end
197
+ rescue External::SlidictIo::Auth::Error, KeyError => e
198
+ @output.puts "Error: GitHub auth failed (#{e.message})"
199
+ 1
200
+ end
201
+
202
+ def slides(args)
203
+ slides_command.run(args)
204
+ end
205
+
206
+ def serve(args)
207
+ server.run(args)
208
+ end
209
+
210
+ def lint(args)
211
+ lint_command.run(args)
212
+ end
213
+
214
+ def publish_to_slidict(deck, content, options)
215
+ slides_command.publish(
216
+ id: options[:slide_id],
217
+ title: options[:slide_title] || deck.topic,
218
+ body: content,
219
+ body_format: body_format_for(deck.framework),
220
+ visibility: options[:visibility]
221
+ )
222
+ end
223
+
224
+ def slides_command
225
+ @slides_command ||= Slides.new(output: @output, credentials: @credentials, reauthenticate: method(:auth))
226
+ end
227
+
228
+ def server
229
+ @server ||= Serve.new(output: @output)
230
+ end
231
+
232
+ def lint_command
233
+ @lint_command ||= Lint.new(output: @output)
234
+ end
235
+
236
+ def body_format_for(framework)
237
+ Output::Format.fetch(framework).body_format
238
+ end
239
+
240
+ def login_expired
241
+ @output.puts "Error: GitHub auth timed out. Run `slidict auth` and try again."
242
+ 1
243
+ end
244
+
245
+ def fetch_value!(args, option)
246
+ value = args.shift
247
+ raise ArgumentError, "#{option} requires a value" if value.nil? || value.start_with?("-")
248
+
249
+ value
250
+ end
251
+
252
+ def ask(question, provided)
253
+ return provided unless provided.nil? || provided.strip.empty?
254
+
255
+ @output.puts question
256
+ @output.print "> "
257
+ @input.gets&.chomp.to_s
258
+ end
259
+
260
+ def print_help
261
+ @output.puts <<~HELP
262
+ Usage: slidict [options]
263
+ Usage: slidict auth
264
+ Usage: slidict slides <list|show|create|edit> [options]
265
+ Usage: slidict serve [sinatra options]
266
+ Usage: slidict lint <file> [options]
267
+
268
+ Generate presentation source files from a short conversation.
269
+
270
+ Commands:
271
+ auth Authenticate the CLI with GitHub and save a CLI access token
272
+ slides Manage your slides on slidict.io (run `slidict slides -h` for details)
273
+ serve Serve slide files from ./public with Sinatra
274
+ lint Check whether a slide deck's structure will land with its audience
275
+ (run `slidict lint -h` for details)
276
+
277
+ Options:
278
+ --topic TEXT Presentation topic
279
+ --duration TEXT Presentation length, for example "5 minutes"
280
+ --audience TEXT Target audience
281
+ --goal TEXT Desired audience takeaway or action
282
+ --framework NAME #{Output::Format.names.join(", ")} (default: slidev)
283
+ --filename NAME File name under public/ (default: next sequential file)
284
+ --llm-base-url URL OpenAI Compatible API base URL (env: SLIDICT_LLM_BASE_URL).
285
+ When omitted, the built-in slide template is used instead.
286
+ --llm-api-key KEY API key for the LLM endpoint (env: SLIDICT_LLM_API_KEY)
287
+ --llm-model NAME Model name to request (env: SLIDICT_LLM_MODEL, default: gpt-4o-mini)
288
+ --no-llm Skip the LLM call and use the built-in slide template
289
+ --publish Publish the generated slides to slidict.io as a draft
290
+ (requires `slidict auth`; creates a new slide, or edits
291
+ an existing one when --slide-id is given)
292
+ --slide-id ID Edit this existing draft instead of creating a new one
293
+ (implies --publish)
294
+ --slide-title TEXT Title for the published slide (default: --topic)
295
+ --visibility VIS public, unlisted, or group_only (default: public)
296
+ -o, --output PATH Output file (overrides --filename and the public/ default)
297
+ -h, --help Show this help
298
+ HELP
299
+ 0
300
+ end
301
+
302
+ def output_path_for(framework, filename)
303
+ return File.join("public", normalize_filename(filename, framework)) if filename
304
+
305
+ next_sequential_output_for(framework)
306
+ end
307
+
308
+ def normalize_filename(filename, framework)
309
+ path = filename.to_s.strip
310
+ raise ArgumentError, "--filename requires a relative path under public" if path.empty?
311
+ raise ArgumentError, "--filename must be relative" if Pathname.new(path).absolute?
312
+ raise ArgumentError, "--filename cannot include .." if Pathname.new(path).each_filename.any?("..")
313
+
314
+ # --filename is already relative to public/, so drop a redundant leading
315
+ # "public/" instead of nesting it twice (public/public/...).
316
+ path = path.delete_prefix("public/")
317
+ File.extname(path).empty? ? "#{path}#{default_extension_for(framework)}" : path
318
+ end
319
+
320
+ def next_sequential_output_for(framework)
321
+ extension = default_extension_for(framework)
322
+ number = 1
323
+ loop do
324
+ path = File.join("public", format("%03d%s", number, extension))
325
+ return path unless File.exist?(path)
326
+
327
+ number += 1
328
+ end
329
+ end
330
+
331
+ def default_extension_for(framework)
332
+ Output::Format.fetch(framework).extension
333
+ end
334
+ end
335
+ end
336
+ end