slidict 0.1.9 → 0.2.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: fbc5e790dd61153be274e56924cf62275c6f7faecc62fadeb1be61112d2b7c22
4
- data.tar.gz: 0a1af3694e1bce28b2f21a4d0898fad0034ff6fe3460b9d53e18236668a5b4f4
3
+ metadata.gz: cc62a4a9def18173cd4fa3c36a07b8113cbd952f3ecef69f25d31bdd8a0c2c29
4
+ data.tar.gz: b559163e5add314725911941fece8829b47cd79f801369a4735a5b62fb6aadfb
5
5
  SHA512:
6
- metadata.gz: 7f037d810c7174a44b83f83666d186377edc3171d76bbc130f555f856252976065e752e9a44212369bbe08a2354480305ece22b1962851962366137416ef3f60
7
- data.tar.gz: 141a0c3e8b00c02f35519b293be29b5033ed61ea75959f25a788c6da977d31907c78470a7fba99f7cc8adcf508880e86f90f4e20cde36da21db6d249690e6eff
6
+ metadata.gz: 7482973532b58361216b54dc6a45e81bee4bc39e65e9a26958c37c271cdcb97f5c915649ee26d88973f4e34f26a9a7eb2b56a0ad7224488bd87a739087201d25
7
+ data.tar.gz: 946075256cf69b70511aae347f2d6a15164f87aeb4bd0678ac329af8a367773c515aec5a09bc4cab52a42b475e4aff7ec26995cbb7fce8f191ee3c12accea668
data/AGENTS.md ADDED
@@ -0,0 +1,41 @@
1
+ # AGENTS.md
2
+
3
+ Guidance for AI coding agents working in this repository.
4
+
5
+ ## Project overview
6
+
7
+ Slidict is a Ruby CLI gem that turns conversational input into presentation
8
+ source files (Slidev, Marp, Asciidoctor Reveal.js, etc.) via an
9
+ OpenAI-compatible chat API.
10
+
11
+ ## Setup
12
+
13
+ - Ruby 3.1+
14
+ - Install dependencies: `bundle install`
15
+
16
+ ## Development workflow
17
+
18
+ - Run tests: `bundle exec rspec`
19
+ - Run lint: `bundle exec rubocop`
20
+ - Run the CLI locally: `bin/slidict`
21
+
22
+ Run both tests and lint before considering a change complete.
23
+
24
+ When you add or change a CLI command (or its options), update the "Commands" section
25
+ of `README.md` to document it.
26
+
27
+ ## Commit conventions
28
+
29
+ Use Conventional Commits for every commit message: `<type>: <summary>`.
30
+
31
+ Common types used in this repo:
32
+
33
+ - `feat:` new functionality
34
+ - `fix:` bug fixes
35
+ - `chore:` maintenance, version bumps, dependency updates
36
+ - `ci:` CI/workflow changes
37
+ - `docs:` documentation only changes
38
+ - `refactor:` code change that neither fixes a bug nor adds a feature
39
+ - `test:` adding or correcting tests
40
+
41
+ Keep the summary short and in the imperative mood (e.g. `fix: handle empty topic input`).
data/README.md CHANGED
@@ -53,6 +53,20 @@ bin/slidict \
53
53
  --output slides.adoc
54
54
  ```
55
55
 
56
+ Add `--publish` to also save the generated slides to slidict.io as a draft (requires
57
+ `slidict auth` first). Pass `--slide-id` to edit an existing draft instead of creating a
58
+ new one:
59
+
60
+ ```bash
61
+ # Create a new draft on slidict.io from the generated slides
62
+ bin/slidict --topic "PDF Difference Monitoring Service" --duration "5 minutes" \
63
+ --audience "Engineering managers" --goal "Approve an MVP pilot" --publish
64
+
65
+ # Edit an existing draft (slide #42) instead of creating a new one
66
+ bin/slidict --topic "PDF Difference Monitoring Service" --duration "5 minutes" \
67
+ --audience "Engineering managers" --goal "Approve an MVP pilot" --slide-id 42
68
+ ```
69
+
56
70
  ## Output files
57
71
 
58
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:
@@ -63,6 +77,39 @@ Marp -> slides.md
63
77
  Asciidoctor Reveal.js -> slides.adoc
64
78
  ```
65
79
 
80
+ ## Commands
81
+
82
+ ### `slidict auth`
83
+
84
+ Authenticates the CLI with your GitHub account via the device code flow and saves a
85
+ CLI access token to `~/.config/slidict/credentials.json`.
86
+
87
+ ```bash
88
+ bin/slidict auth
89
+ ```
90
+
91
+ ### `slidict slides`
92
+
93
+ Manage your slides on slidict.io using the CLI access token saved by `slidict auth`.
94
+
95
+ ```bash
96
+ bin/slidict slides list [--page N]
97
+ bin/slidict slides show <id>
98
+ bin/slidict slides create [--title TEXT] [--body TEXT | --file PATH] [--body-format asciidoc|markdown] [--visibility public|unlisted|group_only]
99
+ bin/slidict slides edit <id> [--title TEXT] [--body TEXT | --file PATH] [--body-format asciidoc|markdown] [--visibility public|unlisted|group_only]
100
+ ```
101
+
102
+ - `create` and `edit` always save the slide as a draft. Publishing requires going through
103
+ the moderation flow on the Web UI; the CLI cannot publish a slide.
104
+ - `edit` only works on slides that are still drafts; editing an already-published slide
105
+ must be done from the Web UI.
106
+ - `create`/`edit` are rate limited to once per minute per user.
107
+
108
+ Run `bin/slidict slides -h` for the full list of options.
109
+
110
+ `bin/slidict --publish` and `--slide-id` (see [Usage](#usage)) wrap this same `create`/`edit`
111
+ behavior so you can save the slides you just generated straight to slidict.io.
112
+
66
113
  ## Configuration
67
114
 
68
115
  Slidict generates slides with an LLM through any OpenAI Compatible API. Configure the
@@ -0,0 +1,75 @@
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 CHANGED
@@ -9,15 +9,22 @@ module Slidict
9
9
  "asciidoctor-revealjs" => "slides.adoc"
10
10
  }.freeze
11
11
 
12
- def initialize(input: $stdin, output: $stdout, renderer: MarkdownRenderer.new)
12
+ def initialize(input: $stdin, output: $stdout, renderer: MarkdownRenderer.new, auth_client: nil,
13
+ credentials: nil, sleeper: Kernel, slides_command: nil)
13
14
  @input = input
14
15
  @output = output
15
16
  @renderer = renderer
17
+ @auth_client = auth_client
18
+ @credentials = credentials
19
+ @sleeper = sleeper
20
+ @slides_command = slides_command
16
21
  end
17
22
 
18
23
  def run(argv = [])
19
24
  options = parse(argv)
20
25
  return print_help if options[:help]
26
+ return auth if options[:command] == "auth"
27
+ return slides(options[:args]) if options[:command] == "slides"
21
28
 
22
29
  config = build_config(options)
23
30
  client = llm_client_for(config)
@@ -34,8 +41,8 @@ module Slidict
34
41
  if client
35
42
  begin
36
43
  slides = client.generate_slides(deck)
37
- rescue LLMClient::Error => error
38
- @output.puts "Error: LLM request failed (#{error.message})"
44
+ rescue LLMClient::Error => e
45
+ @output.puts "Error: LLM request failed (#{e.message})"
39
46
  return 1
40
47
  end
41
48
  deck = Deck.new(
@@ -45,11 +52,15 @@ module Slidict
45
52
  end
46
53
 
47
54
  path = options[:output]
48
- File.write(path, @renderer.render(deck))
55
+ content = @renderer.render(deck)
56
+ File.write(path, content)
49
57
  @output.puts "Created #{path}"
58
+
59
+ return publish_to_slidict(deck, content, options) if options[:publish] || options[:slide_id]
60
+
50
61
  0
51
- rescue ArgumentError => error
52
- @output.puts "Error: #{error.message}"
62
+ rescue ArgumentError => e
63
+ @output.puts "Error: #{e.message}"
53
64
  @output.puts
54
65
  print_help
55
66
  1
@@ -61,6 +72,21 @@ module Slidict
61
72
  options = { framework: "slidev" }
62
73
  args = argv.dup
63
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
+
64
90
  until args.empty?
65
91
  case (arg = args.shift)
66
92
  when "-h", "--help"
@@ -85,6 +111,14 @@ module Slidict
85
111
  options[:llm_model] = fetch_value!(args, arg)
86
112
  when "--no-llm"
87
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)
88
122
  else
89
123
  raise ArgumentError, "unknown option #{arg}"
90
124
  end
@@ -112,11 +146,68 @@ module Slidict
112
146
  def verify_connection(client)
113
147
  client.verify_connection!
114
148
  true
115
- rescue LLMClient::Error => error
116
- @output.puts "Error: LLM request failed (#{error.message})"
149
+ rescue LLMClient::Error => e
150
+ @output.puts "Error: LLM request failed (#{e.message})"
117
151
  false
118
152
  end
119
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
+
120
211
  def fetch_value!(args, option)
121
212
  value = args.shift
122
213
  raise ArgumentError, "#{option} requires a value" if value.nil? || value.start_with?("-")
@@ -135,9 +226,15 @@ module Slidict
135
226
  def print_help
136
227
  @output.puts <<~HELP
137
228
  Usage: slidict [options]
229
+ Usage: slidict auth
230
+ Usage: slidict slides <list|show|create|edit> [options]
138
231
 
139
232
  Generate presentation source files from a short conversation.
140
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
+
141
238
  Options:
142
239
  --topic TEXT Presentation topic
143
240
  --duration TEXT Presentation length, for example "5 minutes"
@@ -149,6 +246,13 @@ module Slidict
149
246
  --llm-api-key KEY API key for the LLM endpoint (env: SLIDICT_LLM_API_KEY)
150
247
  --llm-model NAME Model name to request (env: SLIDICT_LLM_MODEL, default: gpt-4o-mini)
151
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)
152
256
  -o, --output PATH Output file (default depends on --framework)
153
257
  -h, --help Show this help
154
258
  HELP
@@ -0,0 +1,39 @@
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
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "uri"
6
+
7
+ module Slidict
8
+ # Talks to the slidict.io CLI slides API using a CLI access token
9
+ # (obtained via Slidict::AuthClient / `slidict auth`).
10
+ class SlidesClient
11
+ class Error < StandardError; end
12
+ class Unauthorized < Error; end
13
+ class Forbidden < Error; end
14
+ class NotFound < Error; end
15
+ class NotEditable < Error; end
16
+ class RateLimited < Error; end
17
+
18
+ # Raised for 422 responses other than "not_editable", carrying the API's validation errors.
19
+ class Unprocessable < Error
20
+ attr_reader :errors
21
+
22
+ def initialize(message, errors: [])
23
+ super(message)
24
+ @errors = errors
25
+ end
26
+ end
27
+
28
+ DEFAULT_BASE_URL = "https://slidict.io"
29
+
30
+ attr_reader :base_url
31
+
32
+ def initialize(access_token:, token_type: "Bearer", base_url: ENV.fetch("SLIDICT_AUTH_BASE_URL", DEFAULT_BASE_URL))
33
+ @access_token = access_token
34
+ @token_type = token_type
35
+ @base_url = base_url
36
+ end
37
+
38
+ def list(page: nil)
39
+ get_json("/api/cli/slides", query: page ? { page: page } : {})
40
+ end
41
+
42
+ def show(id)
43
+ get_json("/api/cli/slides/#{id}")
44
+ end
45
+
46
+ def create(body:, title: nil, body_format: nil, visibility: nil)
47
+ post_json("/api/cli/slides",
48
+ slide_payload(title: title, body: body, body_format: body_format, visibility: visibility))
49
+ end
50
+
51
+ def update(id, body: nil, title: nil, body_format: nil, visibility: nil)
52
+ patch_json("/api/cli/slides/#{id}",
53
+ slide_payload(title: title, body: body, body_format: body_format, visibility: visibility))
54
+ end
55
+
56
+ private
57
+
58
+ def slide_payload(title:, body:, body_format:, visibility:)
59
+ { title: title, body: body, body_format: body_format, visibility: visibility }.compact
60
+ end
61
+
62
+ def get_json(path, query: {})
63
+ uri = URI.join(base_url, path)
64
+ uri.query = URI.encode_www_form(query) unless query.empty?
65
+ perform(uri, Net::HTTP::Get.new(uri))
66
+ end
67
+
68
+ def post_json(path, payload)
69
+ uri = URI.join(base_url, path)
70
+ perform(uri, build_request(Net::HTTP::Post.new(uri), payload))
71
+ end
72
+
73
+ def patch_json(path, payload)
74
+ uri = URI.join(base_url, path)
75
+ perform(uri, build_request(Net::HTTP::Patch.new(uri), payload))
76
+ end
77
+
78
+ def build_request(request, payload)
79
+ request["Content-Type"] = "application/json"
80
+ request.body = JSON.generate(payload)
81
+ request
82
+ end
83
+
84
+ def perform(uri, request)
85
+ request["Accept"] = "application/json"
86
+ request["Authorization"] = "#{@token_type} #{@access_token}"
87
+
88
+ response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == "https") do |http|
89
+ http.request(request)
90
+ end
91
+ body = response.body.to_s.empty? ? {} : JSON.parse(response.body)
92
+ handle_response(response, body)
93
+ rescue JSON::ParserError => e
94
+ raise Error, "invalid JSON response: #{e.message}"
95
+ rescue SystemCallError, Timeout::Error => e
96
+ raise Error, e.message
97
+ end
98
+
99
+ ERROR_CLASSES_BY_STATUS = {
100
+ "401" => Unauthorized,
101
+ "403" => Forbidden,
102
+ "404" => NotFound,
103
+ "429" => RateLimited
104
+ }.freeze
105
+
106
+ def handle_response(response, body)
107
+ return body if response.is_a?(Net::HTTPSuccess)
108
+ return raise_unprocessable(body) if response.code == "422"
109
+
110
+ error_class = ERROR_CLASSES_BY_STATUS.fetch(response.code, Error)
111
+ raise error_class, body["error_description"] || body["error"] || "HTTP #{response.code}"
112
+ end
113
+
114
+ def raise_unprocessable(body)
115
+ raise NotEditable, "not_editable" if body["error"] == "not_editable"
116
+
117
+ raise Unprocessable.new(body["error"] || "unprocessable", errors: Array(body["errors"]))
118
+ end
119
+ end
120
+ end
@@ -0,0 +1,253 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Slidict
4
+ # Implements the `slidict slides <list|show|create|edit>` subcommands.
5
+ class SlidesCommand
6
+ def initialize(output:, credentials: nil, client: nil, reauthenticate: nil)
7
+ @output = output
8
+ @credentials = credentials || Credentials.new
9
+ @client = client
10
+ @reauthenticate = reauthenticate
11
+ end
12
+
13
+ def run(argv)
14
+ options = parse(argv)
15
+ return print_help if options[:help] || options[:subcommand].nil?
16
+
17
+ send(options[:subcommand], options)
18
+ rescue ArgumentError => e
19
+ @output.puts "Error: #{e.message}"
20
+ @output.puts
21
+ print_help
22
+ 1
23
+ end
24
+
25
+ # Creates or edits a draft directly, bypassing argv parsing (and its
26
+ # "looks like a flag" checks, which would reject body text such as
27
+ # YAML frontmatter that happens to start with "-").
28
+ def publish(body:, id: nil, title: nil, body_format: nil, visibility: nil)
29
+ options = { id: id, title: title, body: body, body_format: body_format, visibility: visibility }
30
+ id ? edit(options) : create(options)
31
+ end
32
+
33
+ private
34
+
35
+ def parse(argv)
36
+ args = argv.dup
37
+ subcommand = args.shift
38
+ return { help: true } if subcommand.nil? || %w[-h --help].include?(subcommand)
39
+
40
+ case subcommand
41
+ when "list" then parse_list(args)
42
+ when "show" then parse_show(args)
43
+ when "create" then parse_create(args)
44
+ when "edit" then parse_edit(args)
45
+ else raise ArgumentError, "unknown slides command #{subcommand}"
46
+ end
47
+ end
48
+
49
+ def parse_list(args)
50
+ options = { subcommand: :list }
51
+ until args.empty?
52
+ case (arg = args.shift)
53
+ when "--page" then options[:page] = Integer(fetch_value!(args, arg))
54
+ when "-h", "--help" then options[:help] = true
55
+ else raise ArgumentError, "unknown option #{arg}"
56
+ end
57
+ end
58
+ options
59
+ end
60
+
61
+ def parse_show(args)
62
+ id = args.shift
63
+ raise ArgumentError, "show requires a slide id" if id.nil?
64
+ raise ArgumentError, "unknown option #{args.first}" unless args.empty?
65
+
66
+ { subcommand: :show, id: id }
67
+ end
68
+
69
+ def parse_create(args)
70
+ options = { subcommand: :create }
71
+ parse_body_options!(args, options)
72
+ options
73
+ end
74
+
75
+ def parse_edit(args)
76
+ id = args.shift
77
+ raise ArgumentError, "edit requires a slide id" if id.nil?
78
+
79
+ options = { subcommand: :edit, id: id }
80
+ parse_body_options!(args, options)
81
+ options
82
+ end
83
+
84
+ def parse_body_options!(args, options)
85
+ until args.empty?
86
+ case (arg = args.shift)
87
+ when "--title" then options[:title] = fetch_value!(args, arg)
88
+ when "--body" then options[:body] = fetch_value!(args, arg)
89
+ when "--file" then options[:file] = fetch_value!(args, arg)
90
+ when "--body-format" then options[:body_format] = fetch_value!(args, arg)
91
+ when "--visibility" then options[:visibility] = fetch_value!(args, arg)
92
+ when "-h", "--help" then options[:help] = true
93
+ else raise ArgumentError, "unknown option #{arg}"
94
+ end
95
+ end
96
+ raise ArgumentError, "specify only one of --body or --file" if options[:body] && options[:file]
97
+ end
98
+
99
+ def fetch_value!(args, option)
100
+ value = args.shift
101
+ raise ArgumentError, "#{option} requires a value" if value.nil? || value.start_with?("-")
102
+
103
+ value
104
+ end
105
+
106
+ def list(options)
107
+ print_slide_list(client.list(page: options[:page]))
108
+ 0
109
+ rescue SlidesClient::Error => e
110
+ print_client_error(e)
111
+ end
112
+
113
+ def show(options)
114
+ print_slide_detail(client.show(options[:id]))
115
+ 0
116
+ rescue SlidesClient::NotFound
117
+ @output.puts "Error: slide not found"
118
+ 1
119
+ rescue SlidesClient::Error => e
120
+ print_client_error(e)
121
+ end
122
+
123
+ def create(options)
124
+ body = read_body(options)
125
+ raise ArgumentError, "create requires --body or --file" if body.to_s.strip.empty?
126
+
127
+ submit("Created") do
128
+ client.create(title: options[:title], body: body, body_format: options[:body_format],
129
+ visibility: options[:visibility])
130
+ end
131
+ end
132
+
133
+ def edit(options)
134
+ submit("Updated") do
135
+ client.update(options[:id], title: options[:title], body: read_body(options),
136
+ body_format: options[:body_format], visibility: options[:visibility])
137
+ end
138
+ end
139
+
140
+ def submit(verb)
141
+ reauthenticated = false
142
+ begin
143
+ slide = yield
144
+ @output.puts "#{verb} slide ##{slide["id"]} (draft)"
145
+ print_slide_detail(slide)
146
+ 0
147
+ rescue SlidesClient::NotFound
148
+ @output.puts "Error: slide not found"
149
+ 1
150
+ rescue SlidesClient::NotEditable
151
+ @output.puts "Error: this slide is already published. Edit it from the Web UI instead."
152
+ 1
153
+ rescue SlidesClient::RateLimited
154
+ print_rate_limited
155
+ rescue SlidesClient::Unprocessable => e
156
+ print_unprocessable(e)
157
+ rescue SlidesClient::Unauthorized => e
158
+ if !reauthenticated && reauthenticate!
159
+ reauthenticated = true
160
+ retry
161
+ end
162
+ print_client_error(e)
163
+ rescue SlidesClient::Error => e
164
+ print_client_error(e)
165
+ end
166
+ end
167
+
168
+ def read_body(options)
169
+ return File.read(options[:file]) if options[:file]
170
+
171
+ options[:body]
172
+ end
173
+
174
+ def client
175
+ @client ||= begin
176
+ token = @credentials.read_cli_token
177
+ token = @credentials.read_cli_token if token.nil? && reauthenticate!
178
+ raise ArgumentError, "not authenticated. Run `slidict auth` first." unless token
179
+
180
+ SlidesClient.new(access_token: token[:access_token], token_type: token[:token_type])
181
+ end
182
+ end
183
+
184
+ # Triggers the injected login flow (see Slidict::CLI#auth) and clears the
185
+ # memoized client so the next call to `client` picks up the fresh token.
186
+ def reauthenticate!
187
+ return false unless @reauthenticate
188
+
189
+ @client = nil
190
+ @reauthenticate.call.zero?
191
+ end
192
+
193
+ def print_slide_list(result)
194
+ slides = result["slides"] || []
195
+ if slides.empty?
196
+ @output.puts "No slides found."
197
+ return
198
+ end
199
+
200
+ slides.each do |slide|
201
+ @output.puts "##{slide["id"]} [#{slide["status"]}/#{slide["visibility"]}] " \
202
+ "#{slide["title"]} (updated_at: #{slide["updated_at"]})"
203
+ end
204
+ @output.puts "(more slides available, use --page to see the next page)" if result["has_more"]
205
+ end
206
+
207
+ def print_slide_detail(slide)
208
+ @output.puts "##{slide["id"]} #{slide["title"]}"
209
+ @output.puts "status: #{slide["status"]} visibility: #{slide["visibility"]} updated_at: #{slide["updated_at"]}"
210
+ @output.puts
211
+ @output.puts slide["body"]
212
+ end
213
+
214
+ def print_rate_limited
215
+ @output.puts "Error: rate limited. Create/edit is limited to once per minute. Wait and try again."
216
+ 1
217
+ end
218
+
219
+ def print_unprocessable(error)
220
+ @output.puts "Error: #{error.message}"
221
+ error.errors.each { |message| @output.puts " - #{message}" }
222
+ 1
223
+ end
224
+
225
+ def print_client_error(error)
226
+ @output.puts "Error: #{error.message}"
227
+ 1
228
+ end
229
+
230
+ def print_help
231
+ @output.puts <<~HELP
232
+ Usage: slidict slides <command> [options]
233
+
234
+ Commands:
235
+ list [--page N] List your slides (20 per page)
236
+ show <id> Show a slide's details and body
237
+ create [options] Create a new draft slide
238
+ edit <id> [options] Edit an existing draft slide
239
+
240
+ Create/edit options:
241
+ --title TEXT Slide title
242
+ --body TEXT Slide body text
243
+ --file PATH Read the slide body from a file (instead of --body)
244
+ --body-format FORMAT asciidoc or markdown (default: auto-detected from body)
245
+ --visibility VIS public, unlisted, or group_only (default: public)
246
+ -h, --help Show this help
247
+
248
+ Note: slides are always created/edited as drafts. Publish from the Web UI.
249
+ HELP
250
+ 0
251
+ end
252
+ end
253
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Slidict
4
- VERSION = "0.1.9"
4
+ VERSION = "0.2.0"
5
5
  end
data/lib/slidict.rb CHANGED
@@ -2,11 +2,15 @@
2
2
 
3
3
  require "time"
4
4
 
5
+ require_relative "slidict/auth_client"
5
6
  require_relative "slidict/cli"
6
7
  require_relative "slidict/config"
8
+ require_relative "slidict/credentials"
7
9
  require_relative "slidict/deck"
8
10
  require_relative "slidict/llm_client"
9
11
  require_relative "slidict/markdown_renderer"
12
+ require_relative "slidict/slides_client"
13
+ require_relative "slidict/slides_command"
10
14
  require_relative "slidict/version"
11
15
 
12
16
  module Slidict
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.1.9
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Yusuke Abe
@@ -23,6 +23,7 @@ files:
23
23
  - ".github/workflows/changelog.yml"
24
24
  - ".github/workflows/gem-push.yml"
25
25
  - ".github/workflows/test.yml"
26
+ - AGENTS.md
26
27
  - CHANGELOG.md
27
28
  - CODE_OF_CONDUCT.md
28
29
  - LICENSE
@@ -30,11 +31,15 @@ files:
30
31
  - README.md
31
32
  - Rakefile
32
33
  - lib/slidict.rb
34
+ - lib/slidict/auth_client.rb
33
35
  - lib/slidict/cli.rb
34
36
  - lib/slidict/config.rb
37
+ - lib/slidict/credentials.rb
35
38
  - lib/slidict/deck.rb
36
39
  - lib/slidict/llm_client.rb
37
40
  - lib/slidict/markdown_renderer.rb
41
+ - lib/slidict/slides_client.rb
42
+ - lib/slidict/slides_command.rb
38
43
  - lib/slidict/version.rb
39
44
  homepage: https://labs.slidict.io/slidict/
40
45
  licenses: