discourse_cli_tool 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 688cddf126e1f109caa8824d80fc514566742cff6a5d1a535ccb7a27d8c0bae6
4
+ data.tar.gz: 37e886fb922d7481d80dfe8370419c03f6f45d4a6a6f0eee1086a01c151b7614
5
+ SHA512:
6
+ metadata.gz: b1f8f285a6d0138b95c991713cca48e47f61a42afb633a6e5fc3d9720674a43d78d61deb3308b9511203f5a458dc5cececffada7d5d9162ef811583da32a9177
7
+ data.tar.gz: bd8734e7f6f47197a8a65b7faa2b47d23dd3bbf6d3b7113702a0e3b1c405f8475968215aa76bee4098449457413c925895a803cd10a9d718ad5e3a2fc303c264
data/exe/dsc ADDED
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "discourse_cli"
5
+ DiscourseCli::CLI.start(ARGV)
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "thor"
4
+
5
+ module DiscourseCli
6
+ class CLI < Thor
7
+ def self.exit_on_failure?
8
+ true
9
+ end
10
+
11
+ register(Commands::Config, "config", "config <command>", "Manage site configuration")
12
+ register(Commands::Categories, "categories", "categories <command>", "Manage categories")
13
+ register(Commands::Topics, "topics", "topics <command>", "Manage topics")
14
+ register(Commands::Posts, "posts", "posts <command>", "Manage posts")
15
+ end
16
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "discourse_api"
4
+
5
+ module DiscourseCli
6
+ class ClientFactory
7
+ def self.build(config)
8
+ missing = []
9
+ missing << "host" unless config.host
10
+ missing << "api_key" unless config.api_key
11
+ missing << "api_username" unless config.api_username
12
+
13
+ if missing.any?
14
+ raise "Missing required config: #{missing.join(", ")}. " \
15
+ "Run `dsc config set` or set #{missing.map { |k| "DISCOURSE_#{k.upcase}" }.join(", ")}."
16
+ end
17
+
18
+ DiscourseApi::Client.new(config.host, config.api_key, config.api_username)
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "thor"
4
+
5
+ module DiscourseCli
6
+ module Commands
7
+ class Base < Thor
8
+ class_option :site, type: :string, desc: "Named site from config file"
9
+ class_option :host, type: :string, desc: "Discourse host URL"
10
+ class_option :"api-key", type: :string, desc: "API key"
11
+ class_option :"api-username", type: :string, desc: "API username"
12
+ class_option :json, type: :boolean, default: false, desc: "Output JSON"
13
+ class_option :quiet, type: :boolean, default: false, desc: "Suppress output, exit code only"
14
+
15
+ def self.exit_on_failure?
16
+ true
17
+ end
18
+
19
+ private
20
+
21
+ def client
22
+ @client ||= ClientFactory.build(config)
23
+ end
24
+
25
+ def config
26
+ @config ||= DiscourseCli::Config.new(
27
+ site: options[:site],
28
+ host: options[:host],
29
+ api_key: options[:"api-key"],
30
+ api_username: options[:"api-username"],
31
+ )
32
+ end
33
+
34
+ def formatter
35
+ @formatter ||= Formatter.new(json: options[:json], quiet: options[:quiet])
36
+ end
37
+
38
+ def resolve_raw(given_raw)
39
+ return given_raw if given_raw
40
+ return $stdin.read unless $stdin.tty?
41
+ Editor.new.open
42
+ end
43
+
44
+ def handle_error(error)
45
+ message =
46
+ case error
47
+ when DiscourseApi::UnauthenticatedError
48
+ "Authentication failed — check your api_key and api_username"
49
+ when DiscourseApi::NotFoundError
50
+ "Not found"
51
+ when DiscourseApi::UnprocessableEntity
52
+ errors = error.response&.dig(:body, "errors")
53
+ errors ? Array(errors).join("\n") : error.message
54
+ when DiscourseApi::TooManyRequests
55
+ "Rate limited — try again shortly"
56
+ when DiscourseApi::Timeout
57
+ "Request timed out"
58
+ else
59
+ error.message
60
+ end
61
+ $stderr.puts message
62
+ exit 1
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DiscourseCli
4
+ module Commands
5
+ class Categories < Base
6
+ desc "list", "List all categories"
7
+ def list
8
+ formatter.print_list(client.categories)
9
+ rescue DiscourseApi::DiscourseError => e
10
+ handle_error(e)
11
+ end
12
+
13
+ desc "show ID", "Show a category"
14
+ def show(id)
15
+ formatter.print_item(client.category(id.to_i))
16
+ rescue DiscourseApi::DiscourseError => e
17
+ handle_error(e)
18
+ end
19
+
20
+ desc "create", "Create a category"
21
+ option :name, type: :string, required: true
22
+ option :color, type: :string
23
+ option :"text-color", type: :string
24
+ option :description, type: :string
25
+ def create
26
+ params = { name: options[:name] }
27
+ params[:color] = options[:color] if options[:color]
28
+ params[:text_color] = options[:"text-color"] if options[:"text-color"]
29
+ params[:description] = options[:description] if options[:description]
30
+ formatter.print_item(client.create_category(params))
31
+ rescue DiscourseApi::DiscourseError => e
32
+ handle_error(e)
33
+ end
34
+
35
+ desc "update ID", "Update a category"
36
+ option :name, type: :string, required: true
37
+ option :color, type: :string
38
+ option :"text-color", type: :string
39
+ option :description, type: :string
40
+ def update(id)
41
+ params = { id: id.to_i, name: options[:name] }
42
+ params[:color] = options[:color] if options[:color]
43
+ params[:text_color] = options[:"text-color"] if options[:"text-color"]
44
+ params[:description] = options[:description] if options[:description]
45
+ result = client.update_category(params)
46
+ formatter.print_item(result) if result
47
+ formatter.print_success("Updated category #{id}")
48
+ rescue DiscourseApi::DiscourseError => e
49
+ handle_error(e)
50
+ end
51
+
52
+ desc "delete ID", "Delete a category"
53
+ def delete(id)
54
+ client.delete_category(id.to_i)
55
+ formatter.print_success("Deleted category #{id}")
56
+ rescue DiscourseApi::DiscourseError => e
57
+ handle_error(e)
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "yaml"
5
+
6
+ module DiscourseCli
7
+ module Commands
8
+ class Config < Base
9
+ def self.config_path
10
+ DiscourseCli::Config.config_path
11
+ end
12
+
13
+ desc "set", "Save connection details for a named site"
14
+ option :site, type: :string, required: true, desc: "Site name"
15
+ option :host, type: :string, required: true, desc: "Discourse host URL"
16
+ option :"api-key", type: :string, required: true, desc: "API key"
17
+ option :"api-username", type: :string, required: true, desc: "API username"
18
+ def set
19
+ data = load_file
20
+ data["sites"] ||= {}
21
+ data["default"] ||= options[:site]
22
+ data["sites"][options[:site]] = {
23
+ "host" => options[:host],
24
+ "api_key" => options[:"api-key"],
25
+ "api_username" => options[:"api-username"],
26
+ }
27
+ FileUtils.mkdir_p(File.dirname(self.class.config_path))
28
+ File.write(self.class.config_path, YAML.dump(data))
29
+ formatter.print_success("Saved config for site '#{options[:site]}'")
30
+ end
31
+
32
+ desc "list", "List configured sites"
33
+ def list
34
+ data = load_file
35
+ sites = data["sites"] || {}
36
+ default_site = data["default"]
37
+ if sites.empty?
38
+ puts "No sites configured. Run `dsc config set` to add one."
39
+ return
40
+ end
41
+ sites.each do |name, cfg|
42
+ marker = name == default_site ? " (default)" : ""
43
+ puts "#{name}#{marker}"
44
+ puts " host: #{cfg["host"]}"
45
+ puts " api_username: #{cfg["api_username"]}"
46
+ puts
47
+ end
48
+ end
49
+
50
+ private
51
+
52
+ def load_file
53
+ path = self.class.config_path
54
+ File.exist?(path) ? YAML.safe_load(File.read(path)) || {} : {}
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DiscourseCli
4
+ module Commands
5
+ class Posts < Base
6
+ desc "list", "List recent posts"
7
+ def list
8
+ response = client.posts
9
+ items = response["latest_posts"] || []
10
+ formatter.print_list(items)
11
+ rescue DiscourseApi::DiscourseError => e
12
+ handle_error(e)
13
+ end
14
+
15
+ desc "show ID", "Show a post"
16
+ def show(id)
17
+ formatter.print_item(client.get_post(id.to_i))
18
+ rescue DiscourseApi::DiscourseError => e
19
+ handle_error(e)
20
+ end
21
+
22
+ desc "create", "Create a post (opens $EDITOR if --raw not given)"
23
+ option :"topic-id", type: :numeric, required: true
24
+ option :raw, type: :string
25
+ def create
26
+ raw = resolve_raw(options[:raw])
27
+ result = client.create_post(topic_id: options[:"topic-id"], raw: raw)
28
+ formatter.print_item(result)
29
+ rescue DiscourseApi::DiscourseError => e
30
+ handle_error(e)
31
+ end
32
+
33
+ desc "update ID", "Update a post (opens $EDITOR if --raw not given)"
34
+ option :raw, type: :string
35
+ def update(id)
36
+ raw = resolve_raw(options[:raw])
37
+ client.edit_post(id.to_i, raw)
38
+ formatter.print_success("Updated post #{id}")
39
+ rescue DiscourseApi::DiscourseError => e
40
+ handle_error(e)
41
+ end
42
+
43
+ desc "delete ID", "Delete a post"
44
+ def delete(id)
45
+ client.delete_post(id.to_i)
46
+ formatter.print_success("Deleted post #{id}")
47
+ rescue DiscourseApi::DiscourseError => e
48
+ handle_error(e)
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DiscourseCli
4
+ module Commands
5
+ class Topics < Base
6
+ desc "list", "List latest topics"
7
+ option :category, type: :string, desc: "Filter by category slug"
8
+ def list
9
+ params = {}
10
+ params[:category] = options[:category] if options[:category]
11
+ formatter.print_list(client.latest_topics(params))
12
+ rescue DiscourseApi::DiscourseError => e
13
+ handle_error(e)
14
+ end
15
+
16
+ desc "show ID", "Show a topic"
17
+ def show(id)
18
+ formatter.print_item(client.topic(id.to_i))
19
+ rescue DiscourseApi::DiscourseError => e
20
+ handle_error(e)
21
+ end
22
+
23
+ desc "create", "Create a topic (opens $EDITOR if --raw not given)"
24
+ option :title, type: :string, required: true
25
+ option :category, type: :string
26
+ option :tags, type: :string, desc: "Comma-separated tags"
27
+ option :raw, type: :string
28
+ def create
29
+ raw = resolve_raw(options[:raw])
30
+ params = { title: options[:title], raw: raw }
31
+ params[:category] = options[:category] if options[:category]
32
+ params[:tags] = options[:tags].split(",").map(&:strip) if options[:tags]
33
+ formatter.print_item(client.create_topic(params))
34
+ rescue DiscourseApi::DiscourseError => e
35
+ handle_error(e)
36
+ end
37
+
38
+ desc "update ID", "Update a topic"
39
+ option :title, type: :string
40
+ option :category, type: :string, desc: "Category slug or name"
41
+ option :raw, type: :string, desc: "Replace first post content (opens $EDITOR if not given and no other options)"
42
+ def update(id)
43
+ updated_anything = false
44
+
45
+ if options[:title]
46
+ client.rename_topic(id.to_i, options[:title])
47
+ updated_anything = true
48
+ end
49
+
50
+ if options[:category]
51
+ cats = client.categories
52
+ cat = cats.find { |c| c["slug"] == options[:category] || c["name"] == options[:category] }
53
+ unless cat
54
+ $stderr.puts "Category not found: #{options[:category]}"
55
+ exit 1
56
+ end
57
+ client.recategorize_topic(id.to_i, cat["id"])
58
+ updated_anything = true
59
+ end
60
+
61
+ raw =
62
+ if options[:raw]
63
+ options[:raw]
64
+ elsif !updated_anything && !$stdin.tty?
65
+ $stdin.read
66
+ elsif !updated_anything
67
+ Editor.new.open
68
+ end
69
+
70
+ if raw
71
+ topic_data = client.topic(id.to_i)
72
+ first_post_id = topic_data["post_stream"]["posts"][0]["id"]
73
+ client.edit_post(first_post_id, raw)
74
+ end
75
+
76
+ formatter.print_success("Updated topic #{id}")
77
+ rescue DiscourseApi::DiscourseError => e
78
+ handle_error(e)
79
+ end
80
+
81
+ desc "delete ID", "Delete a topic"
82
+ def delete(id)
83
+ client.delete_topic(id.to_i)
84
+ formatter.print_success("Deleted topic #{id}")
85
+ rescue DiscourseApi::DiscourseError => e
86
+ handle_error(e)
87
+ end
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module DiscourseCli
6
+ class Config
7
+ def self.config_path
8
+ File.expand_path("~/.config/dsc/config.yml")
9
+ end
10
+
11
+ def initialize(overrides = {})
12
+ @overrides = overrides
13
+ @config_path_snapshot = self.class.config_path
14
+ end
15
+
16
+ def host
17
+ @overrides[:host] || ENV["DISCOURSE_HOST"] || site_config["host"]
18
+ end
19
+
20
+ def api_key
21
+ @overrides[:api_key] || ENV["DISCOURSE_API_KEY"] || site_config["api_key"]
22
+ end
23
+
24
+ def api_username
25
+ @overrides[:api_username] || ENV["DISCOURSE_API_USERNAME"] || site_config["api_username"]
26
+ end
27
+
28
+ def sites
29
+ file_config["sites"] || {}
30
+ end
31
+
32
+ def default_site
33
+ file_config["default"]
34
+ end
35
+
36
+ private
37
+
38
+ def site_name
39
+ @overrides[:site] || ENV["DISCOURSE_SITE"] || file_config["default"]
40
+ end
41
+
42
+ def site_config
43
+ return {} unless site_name
44
+ (file_config["sites"] || {})[site_name] || {}
45
+ end
46
+
47
+ def file_config
48
+ @file_config ||=
49
+ if File.exist?(@config_path_snapshot)
50
+ YAML.safe_load(File.read(@config_path_snapshot)) || {}
51
+ else
52
+ {}
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tempfile"
4
+ require "shellwords"
5
+
6
+ module DiscourseCli
7
+ class Editor
8
+ def initialize(spawn: nil)
9
+ @spawn = spawn || method(:default_spawn)
10
+ end
11
+
12
+ def open(initial_content = "")
13
+ Tempfile.create(["dsc", ".md"]) do |f|
14
+ f.write(initial_content)
15
+ f.flush
16
+ @spawn.call(f.path)
17
+ f.rewind
18
+ f.read
19
+ end
20
+ end
21
+
22
+ private
23
+
24
+ def default_spawn(path)
25
+ editor = ENV.fetch("EDITOR", "vi")
26
+ system("#{editor} #{Shellwords.escape(path)}")
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module DiscourseCli
6
+ class Formatter
7
+ def initialize(output: $stdout, json: false, quiet: false)
8
+ @output = output
9
+ @json = json
10
+ @quiet = quiet
11
+ end
12
+
13
+ def print_list(items)
14
+ return if @quiet
15
+ if @json
16
+ @output.puts JSON.pretty_generate(items)
17
+ else
18
+ items.each { |item| @output.puts format_row(item) }
19
+ end
20
+ end
21
+
22
+ def print_item(item)
23
+ return if @quiet
24
+ if @json
25
+ @output.puts JSON.pretty_generate(item)
26
+ else
27
+ item.each { |k, v| @output.puts "#{k}: #{v}" }
28
+ end
29
+ end
30
+
31
+ def print_success(message)
32
+ return if @quiet || @json
33
+ @output.puts message
34
+ end
35
+
36
+ private
37
+
38
+ def format_row(item)
39
+ id = item["id"] || item[:id]
40
+ label = item["title"] || item["name"] || item["slug"] || item.values.compact.first
41
+ "#{id}\t#{label}"
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DiscourseCli
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "discourse_cli/version"
4
+ require_relative "discourse_cli/config"
5
+ require_relative "discourse_cli/client_factory"
6
+ require_relative "discourse_cli/editor"
7
+ require_relative "discourse_cli/formatter"
8
+ require_relative "discourse_cli/commands/base"
9
+ require_relative "discourse_cli/commands/config"
10
+ require_relative "discourse_cli/commands/categories"
11
+ require_relative "discourse_cli/commands/topics"
12
+ require_relative "discourse_cli/commands/posts"
13
+ require_relative "discourse_cli/cli"
metadata ADDED
@@ -0,0 +1,137 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: discourse_cli_tool
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Tim Case
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: base64
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: discourse_api
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '2.1'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '2.1'
40
+ - !ruby/object:Gem::Dependency
41
+ name: thor
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '1.0'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '1.0'
54
+ - !ruby/object:Gem::Dependency
55
+ name: minitest
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: '5.0'
61
+ type: :development
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: '5.0'
68
+ - !ruby/object:Gem::Dependency
69
+ name: rake
70
+ requirement: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - "~>"
73
+ - !ruby/object:Gem::Version
74
+ version: '13.0'
75
+ type: :development
76
+ prerelease: false
77
+ version_requirements: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - "~>"
80
+ - !ruby/object:Gem::Version
81
+ version: '13.0'
82
+ - !ruby/object:Gem::Dependency
83
+ name: webmock
84
+ requirement: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - "~>"
87
+ - !ruby/object:Gem::Version
88
+ version: '3.0'
89
+ type: :development
90
+ prerelease: false
91
+ version_requirements: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - "~>"
94
+ - !ruby/object:Gem::Version
95
+ version: '3.0'
96
+ email:
97
+ - tim@2drops.net
98
+ executables:
99
+ - dsc
100
+ extensions: []
101
+ extra_rdoc_files: []
102
+ files:
103
+ - exe/dsc
104
+ - lib/discourse_cli.rb
105
+ - lib/discourse_cli/cli.rb
106
+ - lib/discourse_cli/client_factory.rb
107
+ - lib/discourse_cli/commands/base.rb
108
+ - lib/discourse_cli/commands/categories.rb
109
+ - lib/discourse_cli/commands/config.rb
110
+ - lib/discourse_cli/commands/posts.rb
111
+ - lib/discourse_cli/commands/topics.rb
112
+ - lib/discourse_cli/config.rb
113
+ - lib/discourse_cli/editor.rb
114
+ - lib/discourse_cli/formatter.rb
115
+ - lib/discourse_cli/version.rb
116
+ homepage: https://github.com/timcase/discourse-cli-tool
117
+ licenses:
118
+ - MIT
119
+ metadata: {}
120
+ rdoc_options: []
121
+ require_paths:
122
+ - lib
123
+ required_ruby_version: !ruby/object:Gem::Requirement
124
+ requirements:
125
+ - - ">="
126
+ - !ruby/object:Gem::Version
127
+ version: 2.7.0
128
+ required_rubygems_version: !ruby/object:Gem::Requirement
129
+ requirements:
130
+ - - ">="
131
+ - !ruby/object:Gem::Version
132
+ version: '0'
133
+ requirements: []
134
+ rubygems_version: 4.0.10
135
+ specification_version: 4
136
+ summary: CLI tool for managing Discourse forums from the command line
137
+ test_files: []