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.
@@ -0,0 +1,650 @@
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
108
+ end
109
+ end
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"
147
+ end
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]
169
+ end
170
+
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
179
+ end
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
229
+ end
230
+ { "status" => code, "data" => parsed }
231
+ else
232
+ parsed_err = begin
233
+ JSON.parse(raw_body)
234
+ rescue StandardError
235
+ raw_body
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
271
+
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)
353
+ end
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)
361
+ end
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)}"
381
+ end
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(' ')}"
482
+ end
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
585
+ end
586
+ o.on("-h", "--help", "Show this help") do
587
+ puts o
588
+ exit 0
589
+ end
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
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
647
+ end
648
+ end
649
+
650
+ main(ARGV) unless ENV["SEPTOBER_CLI_NO_AUTORUN"] == "1"
data/blueprint.yaml CHANGED
@@ -5,7 +5,7 @@ ricc-app:
5
5
  maturity: idea
6
6
  app_name: ric-gem
7
7
  lang: Ruby
8
- version: 0.14.6
8
+ version: 0.15.0
9
9
  emoji: "\U0001F48E"
10
10
  owner: github.com/palladius
11
11
  app_description:
data/lib/ric/conf.rb CHANGED
@@ -31,7 +31,7 @@ so you can call load_auto_conf('foo') and it will look throughout any ./.foo.yml
31
31
  # if YML exists return the load..
32
32
  file = "#{dir}/#{fp}"
33
33
  deb " - FILE: #{file}"
34
- if File.exists?(file)
34
+ if File.exist?(file)
35
35
  puts "Found! #{green file}"
36
36
  yaml = YAML.load( File.read(file) )
37
37
  puts "load_auto_conf('#{confname}', v#{libver}) found: #{green yaml}" if verbose
data/lib/ric/files.rb CHANGED
@@ -13,11 +13,11 @@ module Ric
13
13
  verbose = opts.fetch :verbose, true
14
14
  dryrun = opts.fetch :dryrun, true # i scared of copying files!
15
15
 
16
- unless File.exists?("#{to}/.git")
16
+ unless File.exist?("#{to}/.git")
17
17
  fatal 11,"Sorry cant travase data to an unversioned dir. Please version it with git (or add a .git dir/file to trick me)"
18
18
  exit 1
19
19
  end
20
- unless File.exists?("#{to}/.safe_xcopy")
20
+ unless File.exist?("#{to}/.safe_xcopy")
21
21
  fatal 12, "Sorry I refuse to xcopy data unless you explicitly ask me to. You have to do this before:\n #{yellow "touch #{to}/.safe_xcopy"} .
22
22
  You are for sure a very smart person but there are a LOT of people out there who could destroy theyr file system! Thanks"
23
23
  end
@@ -36,7 +36,7 @@ module Ric
36
36
  destdir = File.dirname(to_file)
37
37
  deb "Copying: #{yellow from_file}.."
38
38
  # could need creating the dir..
39
- if File.exists?(destdir)
39
+ if File.exist?(destdir)
40
40
  # just copy the file
41
41
  command = "cp \"#{from_file}\" \"#{to_file}\""
42
42
  else