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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +5 -0
- data/VERSION +1 -1
- data/bin/riclib-test +1 -1
- data/bin/septober +632 -366
- data/bin/septober-cli +650 -0
- data/bin/septober-cli-26 +650 -0
- data/blueprint.yaml +1 -1
- data/lib/ric/conf.rb +1 -1
- data/lib/ric/files.rb +3 -3
- data/lib/ric.rb +12 -9
- metadata +19 -70
- data/Gemfile +0 -23
- data/Makefile +0 -59
- data/Manifest +0 -32
- data/Rakefile +0 -56
- data/TODO +0 -3
- data/ric.gemspec +0 -27
- data/test/sample_conf.yml +0 -17
- data/test/strings_helper.rb +0 -17
- data/test/test_helper.rb +0 -42
- data/var/www/index.html +0 -5
data/bin/septober-cli-26
ADDED
|
@@ -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
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.
|
|
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.
|
|
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.
|
|
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.
|
|
39
|
+
if File.exist?(destdir)
|
|
40
40
|
# just copy the file
|
|
41
41
|
command = "cp \"#{from_file}\" \"#{to_file}\""
|
|
42
42
|
else
|