ric 0.14.6 → 0.15.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.
data/bin/septober CHANGED
@@ -1,384 +1,650 @@
1
- #!/usr/bin/env ruby
2
-
3
- =begin
4
- ############################################################
5
- @author: Riccardo Carlesso
6
- @email: riccardo.carlesso@gmail.com
7
- @url: http://github.com/palladius/septober
8
- @script: septober
9
- @maturity: development
10
- @language: Ruby
11
- @tags: development, rcarlesso
12
- @synopsis: This connects to septober websites. For more info, look here
13
- @description:
14
- septober.heroku.com client to add and list todos!
15
-
16
- TODO
17
- - add --context|-x : to change context (--local => --context 'local' , default: "septober" )
18
-
19
- API: add actions (done, toggle, ..., due <today|tomorrow|monday> )
20
- ###########################################################
21
- =end
22
-
23
- $PROG_VER = '1.0.2'
24
- =begin
25
- @history:
26
- 1.0.1 2022-01-06 Fixing bug
27
- 1.0.0 ????
28
- 0.9.8 2011-02-10 Added timeout (works) and a broken prototype for :edit
29
- 0.9.7 2011-02-09 mini banner WITH version (not to get nuts again thru versions!). --conf now works!
30
- 0.9.6 2011-02-09 CLI supports --conf now. Tags working!
31
- 0.9.5 2011-02-08 more concise 'add'
32
- 0.9.4 2011-02-04 added 'done' action (and 'delete' ?)
33
- 0.9.3 2011-02-04 better 'list'
34
- 0.9.2 2011-02-04 'add' operation succefully added!
35
- 0.9.1 2011-01-18 First version
36
- =end
37
-
38
-
39
- require 'optparse'
40
- require 'rubygems'
41
- require 'ric'
42
- #require 'active_resource' # not needed anymore, after ric gem v0.12.0
43
- require 'active_resource' # restored since i can then debug without installing the gem
44
- require 'socket'
45
- gem 'psych', '=4.0.3'
46
- require 'psych'
47
-
48
- include RubyClasses::Array
49
-
50
- $DEBUG = false
51
-
52
- $opts = {
53
- :app_name => 'septober client',
54
- :hello => 'Welcome to this terrific application',
55
- :septober_url => 'http://septober.heroku.com/' ,
56
- :dflt_config_file => '~/.septober.yml' ,
57
- :local => false ,
58
- :timeout => 15 ,
59
- :subconf => nil , # if nil it goes to septober/septober_local
60
- }
61
-
62
- def init
63
- $opts[:config_file] = $opts[:dflt_config_file]
64
- $optparse = OptionParser.new do |opts|
65
- opts.on( '-c', '--config <FILE>', 'uses a different configfile from: '+$opts[:dflt_config_file] ) {|arg_file|
66
- puts "Importing file different from default: #{yellow arg_file}"
67
- $opts[:config_file] = arg_file
68
- }
69
- opts.on( '-d', '--debug', 'enables debug (DFLT=false)' ) { $opts[:debug] = true ; $debug = true ; debug_on('Debug enabled from ARGV')}
70
- opts.on( '-h', '--help', 'Display this screen' ) { puts(opts); exit 1 }
71
- opts.on( '-n', '--no-colors', 'Deactives colors (onta e disonore!)' ) { $colors_active = false }
72
- opts.on( '-l', '--local', 'uses "septober_local" instead of septober in YAML' ) { $opts[:local] = true }
73
- opts.on( '-s', '--subconf SUBCONF', 'uses different subconf in YAML file (dflt: "septober"/"local")') {|subconf|
74
- $opts[:subconf] = subconf
75
- }
76
- opts.on( '-t', '--timeout TIMEOUT', "Sets timeout (default =#{$opts[:timeout]}s)" ) {|new_timeout|
77
- $opts[:timeout] = new_timeout.to_i rescue 5
78
- RemoteTodo.timeout = new_timeout.to_i rescue 5
79
- }
80
- opts.on( '-v', '--verbose', 'Output more information' ) { $opts[:verbose] = true}
81
-
82
- opts.banner = <<-API_OPTIONS
83
- #{$0} v.#{$PROG_VER}
84
- Usage: #{File.basename $0} [options] [add|list|done|del] [args]
85
-
86
- API Actions (T stands for ToBeDoneYet ):
87
- add <FREE TEXT> # adds new task in free text with some magic..
88
- delete <ID> # T delete ticket.ID (DONT USE IF U CAN!)
89
- done <ID> # T posts ticket.ID :done (resolved)
90
- edit <ID> [ACTION1:ARG1] [ACT2:ARG2]# T posts generic action for ticket.ID /edit.xml?due=tomorrow&ACT2=ARG2
91
- list # shows a list of your todos yet to complete
92
- mail <ID> # T mail to yourself ticket.ID !!!
93
- show <ID> # shows details about todo with id=ID
94
- procrastinate <ID> <NDAYS> # T mail to yourself ticket.ID !!!
95
- sleep <ID> <NDAYS> # T mail to yourself ticket.ID !!!
96
-
97
- Examples:
98
- septober --local edit 110 due:tomorrow
99
- septober list
100
- septober show 1
101
- septober add 'This is a ticket with priority 4 and a simple @example tag!'
102
-
103
- Options:
104
- API_OPTIONS
105
- end
106
- $optparse.parse!
107
- RemoteTodo.import_config()
108
- end
109
-
110
- #################################################################################################################################################
111
- class RemoteTodo < ActiveResource::Base
112
- # With a singleton u can define the accessors of the class (attr accessor for self!)
113
- #class << self
114
- # attr_accessor :description # = '-'
115
- #end
116
- self.site = "http://septober.example.com/" # uninitialized
117
- self.element_name = 'todo'
118
- self.timeout = $opts[:timeout] rescue 6
119
- #self.priority = 3 # default
120
-
121
- @@sample_yml_config = <<-SAMPLE_YML_CONFIG
122
- local: &DEVEL_LOCAL
123
- # YAML file with septober configuration. For more info: http://septober.heroku.com/
124
- site: "http://localhost:3000/api/" # or http://septober.heroku.com/api/
125
- user: guest
126
- password: guest
127
- description: My Rails 3 development (guest//guest shuld work for real This is NOT a random thing)
128
- remote:
129
- site: http://your_server.example.com/api/
130
- user: USERNAME
131
- pass: V3ry_D1ff1cul1
132
- description: you can use your own server. this is open source. Just remember to say Riccardo you think hes a genius. I dont want money, just fame.
133
- #########################################################################################################
134
-
135
- #########################################################################################################
136
- # YAML file with septober configuration. For more info: http://septober.heroku.com/
137
- # You just change here:
138
- #remote:
139
- # <<: *RICCARDO_HEROKU
140
- #local:
141
- # <<: *DEVEL_LOCAL
142
- # IMPORTANT! It looks like psych gem doesnt expand these magic &HEROKU/*HEROKU so better remove them and simplify with dereferencing :/
143
- SAMPLE_YML_CONFIG
144
-
145
- # self.ssl_options = {:cert => OpenSSL::X509::Certificate.new(File.open(pem_file))
146
- # :key => OpenSSL::PKey::RSA.new(File.open(pem_file)),
147
- # :ca_path => "/path/to/OpenSSL/formatted/CA_Certs",
148
- # :verify_mode => OpenSSL::SSL::VERIFY_PEER}
149
-
150
- def self.import_config(file=nil)
151
- file ||= $opts[:config_file]
152
- real_file = File.expand_path file
153
- deb "Importing config from #{file}.."
154
- if File.exists?(real_file)
155
- #puts "TODO import from #{file} (local=#{$opts[:local]})"
156
- whole_hash = YAML.load_file(real_file) # rescue { :error => $! }
157
-
158
- sub_conf = $opts[:subconf].nil? ?
159
- ($opts[:local] ? 'local' : 'remote' ) :
160
- $opts[:subconf]
161
- if not whole_hash.keys.include?(sub_conf)
162
- puts "SubConf non existing: #{red(sub_conf.to_s)}. Available sub_confs: #{yellow whole_hash.keys.join(', ')}. Note that 'remote' is the default, 'local' comes from '-l 'and you can set anything with '-s whatevs' :)"
163
- raise "SubConf not existing: #{(sub_conf)}"
164
- end
165
- puts "SubConf: #{yellow(sub_conf.to_s)}. Available sub_confs (you can enforce them with -s new as of 2022!): #{yellow whole_hash.keys.join(', ')}"
166
- conf_hash = whole_hash[sub_conf] rescue nil
167
- self.site = conf_hash['site']
168
- self.user = conf_hash['user']
169
- self.password = conf_hash['password']
170
- self.description = conf_hash['description'] rescue '-'
171
- deb "Conf imported successuflly: #{blue self.user}@#{self.site}"
172
- else
173
- pred "Missing file: #{file} !!! Try to do the following:"
174
- puts "cat > #{file}\n#{@@sample_yml_config rescue "Err#{$!}" }"
175
- exit 79
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # If Ruby < 3.4 is first in PATH (e.g. system Ruby 3.3 on Linux), re-exec into rbenv Ruby 3.4.5+ if available
5
+ if RUBY_VERSION < "3.4.0"
6
+ rbenv_ruby = File.expand_path("~/.rbenv/versions/3.4.5/bin/ruby")
7
+ exec(rbenv_ruby, $PROGRAM_NAME, *ARGV) if File.executable?(rbenv_ruby)
8
+ end
9
+
10
+ require "optparse"
11
+ require "net/http"
12
+ require "uri"
13
+ require "json"
14
+ require "yaml"
15
+ require "socket"
16
+ require "base64"
17
+ require "date"
18
+ require "time"
19
+
20
+ PROG_VER = "2.6.1"
21
+
22
+ # Optional ric gem support; built-in ANSI fallback so CLI is 100% zero-dependency on Ruby 3.4.5+
23
+ $colors_active = $stdout.tty?
24
+ def colorize(code, str)
25
+ return str.to_s unless $colors_active
26
+ "\e[#{code}m#{str}\e[0m"
27
+ end
28
+ def red(s) = colorize(31, s)
29
+ def green(s) = colorize(32, s)
30
+ def yellow(s) = colorize(33, s)
31
+ def blue(s) = colorize(34, s)
32
+ def purple(s) = colorize(35, s)
33
+ def cyan(s) = colorize(36, s)
34
+ def gray(s) = colorize(90, s)
35
+ def bold(s) = colorize(1, s)
36
+
37
+ CONFIG_PATH = File.expand_path("~/.septober.yml")
38
+ LOCAL_CONFIG_PATH = File.expand_path("~/.septober.local.yml")
39
+
40
+ AGENT_ICONS = {
41
+ "ermete" => "🚛",
42
+ "lobby" => "🦞",
43
+ "pux" => "🐾",
44
+ "freevola" => "🧚‍♀️",
45
+ "derek" => "🖥️"
46
+ }.freeze
47
+
48
+ AGENT_ENV_FILES = {
49
+ "lobby" => "~/.openclaw/.env",
50
+ "ermete" => "~/.hermes/.env",
51
+ "pux" => "~/.openclaw/.env",
52
+ "freevola" => "~/.hermes-freevola/.env"
53
+ }.freeze
54
+
55
+ def parse_dotenv_file(path)
56
+ res = {}
57
+ expanded = File.expand_path(path.to_s)
58
+ return res unless File.file?(expanded)
59
+
60
+ File.readlines(expanded, chomp: true).each do |line|
61
+ stripped = line.strip
62
+ next if stripped.empty? || stripped.start_with?("#")
63
+ next unless stripped.include?("=")
64
+
65
+ k, v = stripped.split("=", 2)
66
+ res[k.strip] = v.strip.gsub(/\A['"]|['"]\z/, "")
67
+ end
68
+ res
69
+ rescue StandardError
70
+ res
71
+ end
72
+
73
+ def load_config(conf_path: nil, target_env: nil, target_agent: nil, cli_user: nil, cli_password: nil)
74
+ resolved_path = conf_path
75
+ if resolved_path.nil? || resolved_path.empty?
76
+ candidates = [
77
+ ENV["SEPTOBER_CONFIG"],
78
+ LOCAL_CONFIG_PATH,
79
+ CONFIG_PATH,
80
+ File.expand_path("~/git/gic/.septober.yml"),
81
+ File.expand_path("~/git/gic25/.septober.yml"),
82
+ File.expand_path("../.septober.yml", __dir__)
83
+ ].compact
84
+ resolved_path = candidates.find { |p| File.file?(p) }
85
+ else
86
+ resolved_path = File.expand_path(resolved_path)
87
+ end
88
+
89
+ unless resolved_path && File.file?(resolved_path)
90
+ warn red("Error: Config file not found (checked #{LOCAL_CONFIG_PATH}, #{CONFIG_PATH})")
91
+ exit 1
92
+ end
93
+
94
+ begin
95
+ raw_yaml = File.read(resolved_path)
96
+ data = YAML.safe_load(raw_yaml, aliases: true, permitted_classes: [Symbol, Date, Time]) || {}
97
+ rescue StandardError => e
98
+ warn red("Error reading YAML config #{resolved_path}: #{e.message}")
99
+ exit 1
100
+ end
101
+
102
+ env_name = target_env || ENV["SEPTOBER_ENV"]
103
+ if env_name.nil? || env_name.empty?
104
+ %w[septober production remote local septober_local].each do |cand|
105
+ if data[cand].is_a?(Hash)
106
+ env_name = cand
107
+ break
176
108
  end
177
109
  end
178
-
179
- def to_s
180
- "RemoteTodo('#{self.name}', P#{priority ? priority : '?'})"
181
- end
182
-
183
- def self.description
184
- @@description ||= '-'
110
+ end
111
+
112
+ if (env_name.nil? || !data[env_name].is_a?(Hash)) && target_env == "local" && data["septober_local"].is_a?(Hash)
113
+ env_name = "septober_local"
114
+ end
115
+
116
+ if env_name.nil? || !data[env_name].is_a?(Hash)
117
+ pair = data.find { |_k, v| v.is_a?(Hash) && (v["site"] || v[:site]) }
118
+ env_name = pair[0] if pair
119
+ end
120
+
121
+ unless env_name && data[env_name].is_a?(Hash)
122
+ warn red("Error: Could not find valid environment '#{target_env || 'septober'}' in #{resolved_path}")
123
+ warn "Available environments: #{data.keys.join(', ')}"
124
+ exit 1
125
+ end
126
+
127
+ cfg = data[env_name]
128
+ site = ENV["SEPTOBER_SITE"] || cfg["site"] || cfg[:site] || "http://localhost:8080/api/"
129
+ site = "#{site.to_s.sub(%r{/+\z}, '')}/"
130
+ user = cli_user || ENV["SEPTOBER_USER"] || cfg["user"] || cfg[:user] || "guest"
131
+ password = cli_password || ENV["SEPTOBER_PASSWORD"] || (cfg["password"] || cfg[:password] || "guest").to_s
132
+ agent_icon = nil
133
+
134
+ # Resolve agent profile
135
+ agent_name = target_agent || ENV["SEPTOBER_AGENT"]
136
+ if (agent_name.nil? || agent_name.empty?) && ENV["SEPTOBER_USER"].nil? && cli_user.nil?
137
+ if ENV["HARNESS_NAME"] == "Hermes" || ENV["HARNESS_NICKNAME"] == "Ermete Bottazzi"
138
+ agent_name = "ermete"
139
+ elsif ENV["HARNESS_NAME"] == "Lobby" || ENV["HARNESS_NICKNAME"] == "Lobby"
140
+ agent_name = "lobby"
141
+ elsif ENV["OPENCLAW_AGENT"] && !ENV["OPENCLAW_AGENT"].empty?
142
+ agent_name = ENV["OPENCLAW_AGENT"]
143
+ elsif Dir.pwd.include?(".openclaw")
144
+ agent_name = "lobby"
145
+ elsif Dir.pwd.include?(".hermes")
146
+ agent_name = "ermete"
185
147
  end
186
-
187
- def self.description=(desc)
188
- @@description = desc
148
+ end
149
+
150
+ if agent_name && !agent_name.empty?
151
+ agent_icon = AGENT_ICONS[agent_name]
152
+ yaml_agents = data["agents"].is_a?(Hash) ? data["agents"][agent_name] : nil
153
+ env_file = AGENT_ENV_FILES[agent_name] ? File.expand_path(AGENT_ENV_FILES[agent_name]) : nil
154
+
155
+ agent_user = nil
156
+ agent_pass = nil
157
+
158
+ if ENV["SEPTOBER_AGENT"] == agent_name && ENV["SEPTOBER_USER"] && ENV["SEPTOBER_PASSWORD"]
159
+ agent_user = ENV["SEPTOBER_USER"]
160
+ agent_pass = ENV["SEPTOBER_PASSWORD"]
161
+ elsif env_file && File.file?(env_file)
162
+ dotenv = parse_dotenv_file(env_file)
163
+ agent_user = dotenv["SEPTOBER_USER"]
164
+ agent_pass = dotenv["SEPTOBER_PASSWORD"]
165
+ elsif yaml_agents.is_a?(Hash)
166
+ agent_user = yaml_agents["user"] || yaml_agents[:user]
167
+ agent_pass = (yaml_agents["password"] || yaml_agents[:password]).to_s
168
+ agent_icon ||= yaml_agents["icon"] || yaml_agents[:icon]
189
169
  end
190
170
 
191
- attr_accessor :priority
192
-
193
- def colored_project_name()
194
- colora(project.color || :grey ,project.name)
171
+ if agent_user && agent_pass && !agent_user.empty? && !agent_pass.empty?
172
+ user = agent_user
173
+ password = agent_pass
174
+ else
175
+ hint_file = env_file || "~/.#{agent_name}/.env"
176
+ warn "\n❌ Error: ENV credentials missing for agent '#{agent_name}'!"
177
+ warn " Configure SEPTOBER_USER, SEPTOBER_PASSWORD, SEPTOBER_AGENT in #{hint_file} or environment."
178
+ exit 1
195
179
  end
196
-
197
- # TODO put this into todo.rb model and add it to the controller like "cli_colored_string" and "cli_neutral_string"
198
- def colored_due_explaination(override_explaination=nil)
199
- mycolor = case due_explaination
200
- when 'overdue' ; :red
201
- when 'close' ; :yellow
202
- when 'far' ; :green
203
- else ; :gray
204
- #default ; :gray
205
- end
206
- return colora(mycolor, override_explaination || due_explaination)
180
+ end
181
+
182
+ {
183
+ "env" => env_name,
184
+ "site" => site,
185
+ "user" => user,
186
+ "password" => password,
187
+ "agent" => agent_name,
188
+ "agent_icon" => agent_icon,
189
+ "description" => cfg["description"] || "",
190
+ "config_path" => resolved_path
191
+ }
192
+ end
193
+
194
+ def make_request(cfg, endpoint, method: "GET", body: nil, timeout: 25)
195
+ base_uri = URI.join(cfg["site"], endpoint.to_s.sub(%r{\A/+}, ""))
196
+ http = Net::HTTP.new(base_uri.host, base_uri.port)
197
+ http.use_ssl = (base_uri.scheme == "https")
198
+ http.open_timeout = timeout
199
+ http.read_timeout = timeout
200
+
201
+ req_class = case method.to_s.upcase
202
+ when "GET" then Net::HTTP::Get
203
+ when "POST" then Net::HTTP::Post
204
+ when "PUT" then Net::HTTP::Put
205
+ when "DELETE" then Net::HTTP::Delete
206
+ else raise "Unsupported HTTP method: #{method}"
207
+ end
208
+
209
+ req = req_class.new(base_uri.request_uri)
210
+ req.basic_auth(cfg["user"].to_s, cfg["password"].to_s)
211
+ req["Accept"] = "application/json"
212
+ req["User-Agent"] = "septober-cli/#{PROG_VER} (Ruby/#{RUBY_VERSION})"
213
+
214
+ if body
215
+ req["Content-Type"] = "application/json"
216
+ req.body = JSON.generate(body)
217
+ end
218
+
219
+ resp = http.request(req)
220
+ code = resp.code.to_i
221
+ raw_body = resp.body.to_s
222
+
223
+ if code >= 200 && code < 300
224
+ return { "status" => code, "data" => nil } if raw_body.strip.empty?
225
+ parsed = begin
226
+ JSON.parse(raw_body)
227
+ rescue StandardError
228
+ raw_body
207
229
  end
208
-
209
- def priority_str(priority)
210
- case priority.to_s
211
- when '1'; return '--'
212
- when '2'; return '-'
213
- when '3'; return ' '
214
- when '4'; return '!'
215
- when '5'; return '!!'
216
- end
217
- return "'?' #{priority}"
230
+ { "status" => code, "data" => parsed }
231
+ else
232
+ parsed_err = begin
233
+ JSON.parse(raw_body)
234
+ rescue StandardError
235
+ raw_body
218
236
  end
237
+ { "status" => code, "error" => parsed_err }
238
+ end
239
+ rescue StandardError => e
240
+ { "status" => 0, "error" => e.message }
241
+ end
242
+
243
+ def format_priority(prio)
244
+ case prio.to_i
245
+ when 5 then "🔴 P0/Urgent"
246
+ when 4 then "🟠 P1/High"
247
+ when 3 then "🟡 P2/Med"
248
+ when 2 then "🟢 P3/Low"
249
+ when 1 then "⚪️ P4/Backlog"
250
+ else "P#{prio}"
251
+ end
252
+ end
253
+
254
+ def extract_todo_id(raw_arg)
255
+ str = raw_arg.to_s.strip
256
+ if str =~ %r{/todos/(\d+)}
257
+ Regexp.last_match(1).to_i
258
+ elsif str =~ /\A#?(\d+)\z/
259
+ Regexp.last_match(1).to_i
260
+ else
261
+ warn red("Error: Invalid Todo ID or URL '#{raw_arg}'. Expected an integer ID (e.g. 235) or URL (.../todos/235).")
262
+ exit 1
263
+ end
264
+ end
265
+
266
+ def local_hostname
267
+ Socket.gethostname.split(".").first
268
+ rescue StandardError
269
+ "unknown-host"
270
+ end
219
271
 
220
- def show_entry(opts={})
221
- mywhere = where ? azure( " @#{where}") : ''
222
- mytags = (tag_list != [] ?
223
- ' '+tag_list.map{|tag| orange("@#{tag}")}.join(' ') :
224
- ''
225
- ) rescue ''
226
- return sprintf "%-3d %-30s %2s %s %s%s",
227
- id, colored_project_name , priority_str(priority), colored_due_explaination(due), name, mywhere+mytags
272
+ def harness_co_author(agent_name = nil)
273
+ return ENV["CARLESSIAN_HARNESS_CO_AUTHOR"] if ENV["CARLESSIAN_HARNESS_CO_AUTHOR"]
274
+
275
+ env_file = AGENT_ENV_FILES[agent_name]
276
+ if env_file
277
+ dotenv = parse_dotenv_file(env_file)
278
+ return dotenv["CARLESSIAN_HARNESS_CO_AUTHOR"] if dotenv["CARLESSIAN_HARNESS_CO_AUTHOR"]
279
+ end
280
+
281
+ case agent_name
282
+ when "ermete" then "Ermete Bottazzi <ermete.bottazzi@romagnatrucking.it>"
283
+ when "lobby" then "Lobby <lobby@openclaw.local>"
284
+ when "pux" then "Pux <pux@openclaw.local>"
285
+ when "freevola" then "Freevola <freevola@pupurabbux.local>"
286
+ end
287
+ end
288
+
289
+ def harness_nickname(agent_name = nil)
290
+ return ENV["HARNESS_NICKNAME"] if ENV["HARNESS_NICKNAME"]
291
+ return ENV["HARNESS_NAME"] if ENV["HARNESS_NAME"]
292
+
293
+ env_file = AGENT_ENV_FILES[agent_name]
294
+ if env_file
295
+ dotenv = parse_dotenv_file(env_file)
296
+ return dotenv["HARNESS_NICKNAME"] || dotenv["HARNESS_NAME"] if dotenv["HARNESS_NICKNAME"] || dotenv["HARNESS_NAME"]
297
+ end
298
+
299
+ case agent_name
300
+ when "ermete" then "Ermete Bottazzi"
301
+ when "lobby" then "Lobby"
302
+ when "pux" then "Pux"
303
+ when "freevola" then "Freevola"
304
+ end
305
+ end
306
+
307
+ def format_agent_badge(todo)
308
+ u = todo["user"]
309
+ if u.is_a?(Hash) && u["is_agent"]
310
+ icon = u["agent_icon"]
311
+ uname = u["username"].to_s
312
+ agent_key = uname.include?(".") ? uname.split(".").last.downcase : uname.downcase
313
+ icon = AGENT_ICONS.fetch(agent_key, "🤖") if icon.nil? || icon.empty? || icon == "?"
314
+ short_name = agent_key.capitalize
315
+ return " [#{icon} #{short_name}]"
316
+ elsif todo["source"].to_s.include?("cli-26/")
317
+ s = todo["source"].to_s
318
+ agent_part = s.split("cli-26/", 2)[1].to_s.split("@").first.to_s
319
+ icon = agent_part.include?("Ermete") ? "🚛" : "🤖"
320
+ return " [#{icon} #{agent_part}]" unless agent_part.empty?
321
+ end
322
+ ""
323
+ end
324
+
325
+ def cmd_list(opts, cfg)
326
+ res = make_request(cfg, "todos.json", timeout: opts[:timeout])
327
+ if res["error"]
328
+ warn red("Error (#{res['status']}): #{res['error']}")
329
+ exit 1
330
+ end
331
+
332
+ todos = res["data"] || []
333
+ if opts[:json]
334
+ puts JSON.pretty_generate(todos)
335
+ return
336
+ end
337
+
338
+ puts "\n#{bold("🚛 Septober [#{cfg['env']}]")} (#{cyan(cfg['site'])}) - User: #{yellow(cfg['user'])} #{gray("[Ruby #{RUBY_VERSION}]")}"
339
+ puts "=" * 75
340
+
341
+ shown = 0
342
+ todos.each do |raw_t|
343
+ t = raw_t.is_a?(Hash) && raw_t["todo"].is_a?(Hash) ? raw_t["todo"] : raw_t
344
+ next unless t.is_a?(Hash)
345
+
346
+ active = t.key?("active") ? t["active"] : true
347
+ next if !opts[:all] && !active
348
+
349
+ tags = Array(t["tag_list"])
350
+ if opts[:tag]
351
+ wanted = opts[:tag].to_s.sub(/\A[@#]/, "").downcase
352
+ next unless tags.map { |x| x.to_s.downcase }.include?(wanted)
228
353
  end
229
-
230
- def tags_comma_separated_list
231
- orange(tag_list.join(', '))
354
+
355
+ badge = format_agent_badge(t)
356
+ if opts[:filter_agent]
357
+ fa = opts[:filter_agent].to_s.downcase
358
+ uname = t.dig("user", "username").to_s.downcase
359
+ src = t["source"].to_s.downcase
360
+ next if !uname.include?(fa) && !src.include?(fa) && !badge.downcase.include?(fa)
232
361
  end
233
-
234
- def colored_name()
235
- white(name)
362
+
363
+ tid = t["id"]
364
+ name = t["name"] || "Untitled"
365
+ prio = format_priority(t["priority"] || 3)
366
+ due = t["due"] || "No date"
367
+ where = t["where"]
368
+ url = t["url"]
369
+ desc = t["description"].to_s.strip
370
+ state_icon = active ? "⏳" : "✅"
371
+
372
+ puts "#{state_icon} [#{bold("##{tid}")}] #{prio} | Due: #{due}#{badge}"
373
+ puts " #{bold('Title:')} #{name}"
374
+ puts " #{bold('Where:')} #{where}" if where && !where.to_s.empty?
375
+ puts " #{bold('URL:')} #{cyan(url)}" if url && !url.to_s.empty?
376
+ puts " #{bold('Tags:')} #{tags.map { |tg| "##{tg}" }.join(' ')}" unless tags.empty?
377
+ if !desc.empty?
378
+ first_line = desc.lines.first.to_s.strip
379
+ snippet = first_line.length > 90 ? "#{first_line[0..87]}..." : first_line
380
+ puts " #{bold('Ctx:')} #{gray(snippet)}"
236
381
  end
237
-
238
- def show_full_entry(opts={})
239
- attributes = %w{
240
- colored_name id
241
- due hide_until
242
- active depends_on_id
243
- photo_url progress_status
244
- source tags_comma_separated_list
245
- url where
246
- }
247
- key_vals = attributes.map{|attr| [attr,send(attr)] } # array of [key, val]
248
- key_vals << ['ProjectName', "#{project.name} (#{send(project.color,project.color ) rescue white(project.color) })" ]
249
- key_vals.map{|k,v| [k,v] }.select{|k,v| v.to_s != ''}.sort{|x,y| x.first <=> y.first }.map{ |k,v| "#{k}: #{v}" }.join("\n") +
250
- "\n--\n#{description}"
382
+ puts "-" * 75
383
+
384
+ shown += 1
385
+ break if opts[:limit] && shown >= opts[:limit]
386
+ end
387
+
388
+ puts "No matching todos found." if shown == 0
389
+ end
390
+
391
+ def cmd_show(id_arg, opts, cfg)
392
+ todo_id = extract_todo_id(id_arg)
393
+ res = make_request(cfg, "todos/#{todo_id}.json", timeout: opts[:timeout])
394
+ if res["error"]
395
+ warn red("Error fetching todo ##{todo_id} (#{res['status']}): #{res['error']}")
396
+ exit 1
397
+ end
398
+
399
+ raw_t = res["data"]
400
+ t = raw_t.is_a?(Hash) && raw_t["todo"].is_a?(Hash) ? raw_t["todo"] : raw_t
401
+ if t.nil? || !t.is_a?(Hash) || t["id"].nil?
402
+ warn red("❌ Error: Todo ##{todo_id} not found (or not visible to user '#{cfg['user']}' on #{cfg['site']}).")
403
+ exit 1
404
+ end
405
+
406
+ if opts[:json]
407
+ puts JSON.pretty_generate(t)
408
+ return
409
+ end
410
+
411
+ u = t["user"]
412
+ agent_str = ""
413
+ if u.is_a?(Hash) && u["is_agent"]
414
+ icon = u["agent_icon"]
415
+ uname = u["username"].to_s
416
+ agent_key = uname.include?(".") ? uname.split(".").last.downcase : uname.downcase
417
+ icon = AGENT_ICONS.fetch(agent_key, "🤖") if icon.nil? || icon.empty? || icon == "?"
418
+ agent_str = "\nAgent: #{icon} #{uname} (host: #{u['agent_host'] || 'unknown'})"
419
+ end
420
+
421
+ web_base = cfg["site"].to_s.sub(%r{/api/?\z}, "")
422
+ web_link = "#{web_base}/todos/#{t['id']}"
423
+
424
+ puts "\n📋 #{bold("Todo ##{t['id']} - #{t['name']}")}\n" + ("=" * 60)
425
+ puts "Status: #{t['active'] ? green('Active') : gray('Done')}"
426
+ puts "Priority: #{format_priority(t['priority'] || 3)}"
427
+ puts "Project: #{t.dig('project', 'name') || t['project_id']}" if t["project"] || t["project_id"]
428
+ puts "Due: #{t['due'] || 'None'}"
429
+ puts "Where: #{t['where'] || 'None'}"
430
+ puts "URL: #{t['url'] || 'None'}"
431
+ puts "Web Link: #{cyan(web_link)}"
432
+ puts "Tags: #{Array(t['tag_list']).inspect}#{agent_str}"
433
+ puts "\n#{bold('Context / Description:')}\n#{t['description'] || 'None'}\n"
434
+ puts "-" * 60
435
+ if t["active"]
436
+ puts "💡 #{bold('Mark done:')} #{green("septober-cli done #{t['id']}")}"
437
+ else
438
+ puts "💡 #{bold('Reopen todo:')} #{yellow("septober-cli undone #{t['id']}")}"
439
+ end
440
+ puts "💡 #{bold('Toggle state:')} #{cyan("septober-cli toggle #{t['id']}")}"
441
+ puts "💡 #{bold('Delete todo:')} #{red("septober-cli delete #{t['id']}")}\n\n"
442
+ end
443
+
444
+ def cmd_add(positional_args, opts, cfg)
445
+ title = opts[:title] || positional_args.join(" ").strip
446
+ if title.nil? || title.empty?
447
+ warn red("Error: Todo title is required (pass --title '...' or positional text).")
448
+ exit 1
449
+ end
450
+
451
+ hostname = local_hostname
452
+ agent_id = cfg["agent"]
453
+ co_author = harness_co_author(agent_id)
454
+ harness_nick = harness_nickname(agent_id)
455
+
456
+ where_val = opts[:where] || hostname
457
+ source_val = harness_nick ? "cli-26/#{harness_nick}@#{hostname}" : "cli-26@#{hostname}"
458
+
459
+ desc = opts[:description]
460
+ if co_author && (desc.nil? || !desc.include?("Co-authored-by"))
461
+ co_note = "Collected via CLI on #{hostname} (Co-authored-by: #{co_author})"
462
+ desc = desc && !desc.empty? ? "#{desc}\n\n#{co_note}" : co_note
463
+ end
464
+
465
+ proj = opts[:project] || "septober"
466
+ todo_payload = {
467
+ "name" => title,
468
+ "priority" => opts[:priority] || 3,
469
+ "source" => source_val,
470
+ "project" => proj,
471
+ "where" => where_val
472
+ }
473
+ todo_payload["description"] = desc if desc && !desc.empty?
474
+ todo_payload["url"] = opts[:url] if opts[:url]
475
+ todo_payload["due"] = opts[:due] if opts[:due]
476
+
477
+ if opts[:tags] && !opts[:tags].empty?
478
+ tag_items = opts[:tags].split(",").map { |x| x.strip.sub(/\A[#@]+/, "") }.reject(&:empty?)
479
+ unless tag_items.empty?
480
+ todo_payload["tags"] = tag_items
481
+ todo_payload["name"] = "#{title} #{tag_items.map { |t| "@#{t}" }.join(' ')}"
251
482
  end
252
- end # /RemoteTodo
253
- #################################################################################################################################################
254
-
255
- #############################
256
- # Program start..
257
-
258
- # header
259
- def bannerino(desc)
260
- "#septober v#{$PROG_VER} #{white desc.to_s} (#{green RemoteTodo.user} @ #{cyan RemoteTodo.site.to_s.split('/')[2] }, timeout=#{RemoteTodo.timeout})" +
261
- (RemoteTodo.description.length > 2 ? " # #{gray RemoteTodo.description}" : '')
262
- end
263
-
264
- def todo_list(opts={})
265
- show_only_active = opts.fetch :only_active, true
266
- puts bannerino( "Index" )
267
- all = RemoteTodo.find(:all)
268
- all.each{|todo|
269
- puts( todo.show_entry ) unless( show_only_active && ! todo.active )
270
- #deb "Full ToString for #{white todo}:\n\t#{todo.inspect}"
271
- } if all
272
- end
273
-
274
- =begin
275
- Adds a todo with just the subject, the server takes it and parses and does the magic
276
- to assing the correct ptoject and stuff..
277
- =end
278
- def todo_add(words_from_argv)
279
- puts bannerino( "Todo.Add('#{words_from_argv}')" )
280
- # TODO put this into begin/end/catch !!!
281
- t = RemoteTodo.create(
282
- :name => words_from_argv,
283
- :description => "sent by CLI #{$0} v.#{$PROG_VER}, due tomorrow by test",
284
- #:due => Date.tomorrow,
285
- :where => Socket.gethostname,
286
- :source => "CLI v.#{$PROG_VER}"
287
- #:project_id => 5,
288
- #:priority => 4
289
- )
290
- #deb "DEB todo: #{t}"
291
-
292
- puts "Trying to save TODO at all costs: #{t}"
293
- ret = t.save
294
-
295
- #pred t.errors.full_messages if t.errors
296
- puts "Some errors: '#{red t.errors.full_messages.to_s}'" if t.errors # and t.errors.full_messages.to_s != ''
297
-
298
- if ret
299
- puts "Todo created successfully: ##{green t.id.to_s}" # just provides the Id
300
- puts "ret: #{ret}"
301
- else
302
- pred "Some error saving todo: #{t.inspect}"
483
+ end
484
+
485
+ res = make_request(cfg, "todos.json", method: "POST", body: todo_payload, timeout: opts[:timeout])
486
+ if res["error"]
487
+ warn red("Error creating todo (#{res['status']}): #{res['error']}")
488
+ exit 1
489
+ end
490
+
491
+ raw_data = res["data"]
492
+ new_id = if raw_data.is_a?(Hash)
493
+ (raw_data["todo"] || raw_data)["id"] || "?"
494
+ elsif raw_data.to_s =~ /\A\d+\z/
495
+ raw_data.to_s
496
+ else
497
+ "new"
498
+ end
499
+
500
+ web_base = cfg["site"].to_s.sub(%r{/api/?\z}, "")
501
+ puts "✅ #{green("Todo ##{new_id} created successfully!")}"
502
+ puts " Title: #{title}"
503
+ puts " Priority: #{format_priority(opts[:priority] || 3)}"
504
+ puts " Where: 📍 #{where_val}" if where_val
505
+ puts " URL: #{opts[:url]}" if opts[:url]
506
+ puts " Web Link: #{cyan("#{web_base}/todos/#{new_id}")}" if new_id.to_s =~ /\A\d+\z/
507
+ puts " Context: #{desc}" if desc
508
+ end
509
+
510
+ def cmd_action(action_name, id_arg, opts, cfg)
511
+ todo_id = extract_todo_id(id_arg)
512
+ endpoint = "todos/#{todo_id}/#{action_name}.json"
513
+ res = make_request(cfg, endpoint, method: "PUT", timeout: opts[:timeout])
514
+ if res["error"]
515
+ warn red("Error running '#{action_name}' on todo ##{todo_id} (#{res['status']}): #{res['error']}")
516
+ exit 1
517
+ end
518
+ puts "✅ #{green("Todo ##{todo_id}: #{action_name.upcase} completed!")}"
519
+ end
520
+
521
+ def main(argv = ARGV)
522
+ opts = {
523
+ conf: nil,
524
+ env: nil,
525
+ agent: nil,
526
+ local: false,
527
+ timeout: 25,
528
+ all: false,
529
+ limit: 20,
530
+ tag: nil,
531
+ json: false,
532
+ title: nil,
533
+ description: nil,
534
+ url: nil,
535
+ where: nil,
536
+ priority: 3,
537
+ project: "septober",
538
+ due: nil,
539
+ tags: nil,
540
+ user: nil,
541
+ password: nil,
542
+ verbose: false,
543
+ debug: false
544
+ }
545
+
546
+ parser = OptionParser.new do |o|
547
+ o.banner = <<~BANNER
548
+ 🚛 Septober CLI v#{PROG_VER} (Ruby #{RUBY_VERSION})
549
+ Usage:
550
+ #{File.basename($PROGRAM_NAME)} [options] list [--all] [--limit N] [--tag TAG] [--json]
551
+ #{File.basename($PROGRAM_NAME)} [options] show <ID_OR_URL> [--json]
552
+ #{File.basename($PROGRAM_NAME)} [options] add <TITLE> [--description "..."] [--priority 1-5] [--where "..."] [--url "..."] [--tags t1,t2]
553
+ #{File.basename($PROGRAM_NAME)} [options] done <ID_OR_URL>
554
+ #{File.basename($PROGRAM_NAME)} [options] toggle <ID_OR_URL>
555
+ #{File.basename($PROGRAM_NAME)} [options] delete <ID_OR_URL>
556
+
557
+ Options:
558
+ BANNER
559
+
560
+ o.on("-c", "--conf FILE", "--config FILE", "Path to .septober.yml") { |v| opts[:conf] = v }
561
+ o.on("-e", "-s", "--env ENV", "--subconf ENV", "Target environment (e.g. septober, local)") { |v| opts[:env] = v }
562
+ o.on("--agent AGENT", "Agent profile (e.g. ermete, lobby, pux)") { |v| opts[:agent] = v; opts[:filter_agent] = v }
563
+ o.on("-l", "--local", "Force local environment") { opts[:local] = true }
564
+ o.on("-u", "--user USER", "Override Septober username/email") { |v| opts[:user] = v }
565
+ o.on("-p", "--password PASS", "Override Septober password") { |v| opts[:password] = v }
566
+ o.on("-t", "--timeout SEC", Integer, "HTTP timeout in seconds (default: 25)") { |v| opts[:timeout] = v }
567
+ o.on("-n", "--no-colors", "Disable ANSI colors") { $colors_active = false }
568
+ o.on("-a", "--all", "Include completed todos in list") { opts[:all] = true }
569
+ o.on("--limit N", Integer, "Max todos to show in list (default: 20)") { |v| opts[:limit] = v }
570
+ o.on("--tag TAG", "Filter list by tag") { |v| opts[:tag] = v }
571
+ o.on("--json", "Output raw JSON") { opts[:json] = true }
572
+ o.on("--title TITLE", "Todo title for add") { |v| opts[:title] = v }
573
+ o.on("--description DESC", "--desc DESC", "Rich narrative context for add") { |v| opts[:description] = v }
574
+ o.on("--url URL", "Primary reference URL for add") { |v| opts[:url] = v }
575
+ o.on("--where WHERE", "Location/event anchor for add") { |v| opts[:where] = v }
576
+ o.on("--priority PRIO", Integer, "Priority 1 (low) to 5 (urgent)") { |v| opts[:priority] = v }
577
+ o.on("--project PROJ", "Project name (default: septober)") { |v| opts[:project] = v }
578
+ o.on("--due DUE", "Due date (YYYY-MM-DD or today/tomorrow)") { |v| opts[:due] = v }
579
+ o.on("--tags TAGS", "Comma-separated tags (e.g. lavoro,google,tech)") { |v| opts[:tags] = v }
580
+ o.on("-d", "--debug", "Enable debug output") { opts[:debug] = true }
581
+ o.on("-v", "--verbose", "Enable verbose output") { opts[:verbose] = true }
582
+ o.on("-V", "--version", "Show version") do
583
+ puts "septober-cli v#{PROG_VER} (ruby #{RUBY_VERSION})"
584
+ exit 0
303
585
  end
304
- pgray "TODO get meaningful explaination from api/todos_controller!"
305
- #puts "errors: #{ret.errors}"
306
- end
307
-
308
- def todo_show(id)
309
- todo = RemoteTodo.find(id)
310
- puts bannerino("Show(#{id})")
311
- puts( todo.show_full_entry )
312
- end
313
-
314
- def todo_done(id,done_or_undone=true)
315
- todo_generic_put(id,:done)
316
- end
317
- def todo_toggle(id,done_or_undone=true)
318
- todo_generic_put(id,:toggle)
319
- end
320
-
321
- def todo_generic_put(id,action,opts={})
322
- puts bannerino("GenericAction(#{action},#{id})")
323
- begin
324
- todo = RemoteTodo.find(id)
325
- puts "Todo.#{id} ok #{green action}: #{yellow( todo.to_s )}"
326
- put_options = {
327
- :my_action => action,
328
- :from_cli_in_test_ric => Socket.gethostname
329
- }.merge( opts.fetch( :additional_args, {:nis => :bah } ) ) # if there are ,m good. Otherwise, "nisba" :)
330
- deb "todo_generic_put(id=#{id},#{purple action}, #{gray opts.inspect})"
331
- todo.put(action, put_options ) # test stuff
332
- #todo.put(action, :my_action => action, :from_cli_in_test_ric => Socket.gethostname , additional_args) # test stuff
333
- rescue ActiveResource::ResourceNotFound
334
- puts "Sorry ResourceNotFound: #{red $!}"
335
- exit 11
336
- rescue ActiveResource::ServerError
337
- puts "Sorry ServerError: #{red $!}"
338
- exit 12
586
+ o.on("-h", "--help", "Show this help") do
587
+ puts o
588
+ exit 0
339
589
  end
340
- # todo is good
341
- #todo.put(:generic_action, :my_action => action)
342
- end
343
-
344
- def todo_search(q)
345
- puts "# Todo.Search('#{q}') (user: #{blue RemoteTodo.user})"
346
- pred "To Be implemented Yet"
347
- end
348
-
349
- def usage(explaination=nil)
350
- puts $optparse
351
- pred(explaination) if explaination
352
- exit 85
353
- end
354
-
355
-
356
- def main()
357
- #YAML::ENGINE.yamler = 'syck'
358
- init()
359
- # Maybe you may want to check on ARGV
360
- #unless ARGV.size > 0
361
- # usage "Give me at least 1 argument" # Maybe default to list?!?
362
- #end
363
- all_args = ARGV[1..-1].join(' ') rescue ''
364
- case ARGV[0].to_s
365
- when 'list' ; todo_list()
366
- when 'show' ; todo_show(ARGV[1])
367
- when 'done' ; todo_done(ARGV[1],true)
368
- when 'add' ; todo_add(all_args) # all except last one
369
- when 'toggle'; todo_toggle(ARGV[1]) # all except last one
370
- when 'delete'; todo_generic_put(ARGV[1], :delete , :additional_args => {:test_foo => 'test deletion string, with whole DELETE' })
371
- when 'del'; todo_generic_put(ARGV[1], :delete , :additional_args => {:test_foo => 'test deletion string, with just DEL' })
372
- when 'edit'; todo_generic_put(ARGV[1], :edit, :additional_args => ARGV.map{|arg| k,v=arg.split(':') } )
373
- # TODO transform [ [act1, arg1], [act2,arg2]] into {:act1 => arg1, :act2 => arg2 }
374
- when 'tag' ; todo_generic_put(ARGV[1], :additional_args => {:tag_with => all_args.join(',') }) # all except last one
375
- when 'help' ; usage 'Explicitly called help..'
376
- when '' ; todo_list()
377
- else
378
- usage "Unrecognized command: '#{ARGV[0]}'"
590
+ end
591
+
592
+ remaining = parser.parse(argv.dup)
593
+ subcommand = remaining.shift
594
+
595
+ if subcommand.nil?
596
+ puts parser
597
+ exit 0
598
+ end
599
+
600
+ # Support passing a bare ID or Septober URL directly (e.g. `septober-cli 235` or `septober-cli https://.../todos/235`)
601
+ if subcommand =~ /\A#?\d+\z/ || subcommand =~ %r{/todos/\d+}
602
+ remaining.unshift(subcommand)
603
+ subcommand = "show"
604
+ end
605
+
606
+ target_env = opts[:local] ? "local" : opts[:env]
607
+ # For `list`, `--agent` filters by agent unless SEPTOBER_AGENT is explicitly set; for write/show commands, `--agent` selects credentials
608
+ cred_agent = (subcommand == "list") ? ENV["SEPTOBER_AGENT"] : opts[:agent]
609
+ cfg = load_config(
610
+ conf_path: opts[:conf],
611
+ target_env: target_env,
612
+ target_agent: cred_agent,
613
+ cli_user: opts[:user],
614
+ cli_password: opts[:password]
615
+ )
616
+
617
+ case subcommand.downcase
618
+ when "list", "ls"
619
+ cmd_list(opts, cfg)
620
+ when "show", "get", "view"
621
+ id_arg = remaining.first
622
+ if id_arg.nil?
623
+ warn red("Error: 'show' requires a Todo ID or URL.")
624
+ exit 1
379
625
  end
626
+ cmd_show(id_arg, opts, cfg)
627
+ when "add", "create", "new"
628
+ cmd_add(remaining, opts, cfg)
629
+ when "done", "complete"
630
+ id_arg = remaining.first || abort(red("Error: 'done' requires a Todo ID."))
631
+ cmd_action("done", id_arg, opts, cfg)
632
+ when "undone"
633
+ id_arg = remaining.first || abort(red("Error: 'undone' requires a Todo ID."))
634
+ cmd_action("undone", id_arg, opts, cfg)
635
+ when "toggle"
636
+ id_arg = remaining.first || abort(red("Error: 'toggle' requires a Todo ID."))
637
+ cmd_action("toggle", id_arg, opts, cfg)
638
+ when "delete", "del", "rm"
639
+ id_arg = remaining.first || abort(red("Error: 'delete' requires a Todo ID."))
640
+ cmd_action("delete", id_arg, opts, cfg)
641
+ when "version"
642
+ puts "septober-cli v#{PROG_VER} (ruby #{RUBY_VERSION})"
643
+ else
644
+ warn red("Unknown subcommand: '#{subcommand}'")
645
+ puts parser
646
+ exit 1
380
647
  end
648
+ end
381
649
 
382
- #########################################################
383
- # real program...
384
- main()
650
+ main(ARGV) unless ENV["SEPTOBER_CLI_NO_AUTORUN"] == "1"