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 +4 -4
- data/AGENTS.md +41 -0
- data/README.md +47 -0
- data/lib/slidict/auth_client.rb +75 -0
- data/lib/slidict/cli.rb +112 -8
- data/lib/slidict/credentials.rb +39 -0
- data/lib/slidict/slides_client.rb +120 -0
- data/lib/slidict/slides_command.rb +253 -0
- data/lib/slidict/version.rb +1 -1
- data/lib/slidict.rb +4 -0
- metadata +6 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: cc62a4a9def18173cd4fa3c36a07b8113cbd952f3ecef69f25d31bdd8a0c2c29
|
|
4
|
+
data.tar.gz: b559163e5add314725911941fece8829b47cd79f801369a4735a5b62fb6aadfb
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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 =>
|
|
38
|
-
@output.puts "Error: LLM request failed (#{
|
|
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
|
-
|
|
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 =>
|
|
52
|
-
@output.puts "Error: #{
|
|
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 =>
|
|
116
|
-
@output.puts "Error: LLM request failed (#{
|
|
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
|
data/lib/slidict/version.rb
CHANGED
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.
|
|
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:
|