freshbooks-cli 0.3.2 → 0.4.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,1106 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "thor"
4
+ require "json"
5
+ require "date"
6
+ require "io/console"
7
+ require "stringio"
8
+
9
+ module FreshBooks
10
+ module CLI
11
+ class Commands < Thor
12
+ def self.exit_on_failure?
13
+ true
14
+ end
15
+
16
+ class_option :no_interactive, type: :boolean, default: false, desc: "Disable interactive prompts (auto-detected when not a TTY)"
17
+ class_option :interactive, type: :boolean, default: false, desc: "Force interactive mode even when not a TTY"
18
+ class_option :format, type: :string, desc: "Output format: table (default) or json"
19
+ class_option :dry_run, type: :boolean, default: false, desc: "Simulate command without making network calls"
20
+
21
+ no_commands do
22
+ def invoke_command(command, *args)
23
+ Spinner.interactive = interactive?
24
+ return super unless options[:dry_run]
25
+
26
+ Thread.current[:fb_dry_run] = true
27
+ $stderr.puts "[DRY RUN] No changes will be made."
28
+
29
+ if options[:format] == "json"
30
+ original_stdout = $stdout
31
+ buffer = StringIO.new
32
+ $stdout = buffer
33
+ begin
34
+ super
35
+ ensure
36
+ $stdout = original_stdout
37
+ end
38
+ begin
39
+ data = JSON.parse(buffer.string)
40
+ existing_dry_run = data.is_a?(Hash) ? (data["_dry_run"] || {}) : {}
41
+ meta = { "_dry_run" => existing_dry_run.merge("simulated" => true) }
42
+ wrapped = data.is_a?(Array) ? { "_dry_run" => { "simulated" => true } }.merge("data" => data) : data.merge(meta)
43
+ puts JSON.pretty_generate(wrapped)
44
+ rescue JSON::ParserError
45
+ print buffer.string
46
+ end
47
+ else
48
+ super
49
+ end
50
+ ensure
51
+ Thread.current[:fb_dry_run] = false
52
+ end
53
+ end
54
+
55
+ # --- version ---
56
+
57
+ desc "version", "Print the current version"
58
+ def version
59
+ puts "freshbooks-cli #{VERSION}"
60
+ end
61
+
62
+ # --- auth ---
63
+
64
+ desc "auth [SUBCOMMAND] [ARGS]", "Authenticate with FreshBooks via OAuth2 (subcommands: setup, url, callback, status)"
65
+ def auth(subcommand = nil, *args)
66
+ case subcommand
67
+ when "setup"
68
+ config = Auth.setup_config_from_args
69
+ if options[:format] == "json"
70
+ puts JSON.pretty_generate({ "config_path" => Auth.config_path, "status" => "saved" })
71
+ else
72
+ puts "Config saved to #{Auth.config_path}"
73
+ end
74
+
75
+ when "url"
76
+ config = Auth.load_config
77
+ abort("No config found. Run: fb auth setup (set FRESHBOOKS_CLIENT_ID and FRESHBOOKS_CLIENT_SECRET first)") unless config
78
+ url = Auth.authorize_url(config)
79
+ if options[:format] == "json"
80
+ puts JSON.pretty_generate({ "url" => url })
81
+ else
82
+ puts url
83
+ end
84
+
85
+ when "callback"
86
+ config = Auth.load_config
87
+ abort("No config found. Run: fb auth setup (set FRESHBOOKS_CLIENT_ID and FRESHBOOKS_CLIENT_SECRET first)") unless config
88
+ redirect_url = args.first
89
+ abort("Usage: fb auth callback REDIRECT_URL") unless redirect_url
90
+ code = Auth.extract_code_from_url(redirect_url)
91
+ abort("Could not find 'code' parameter in the URL.") unless code
92
+ tokens = Auth.exchange_code(config, code)
93
+
94
+ # Auto-discover businesses
95
+ businesses = Auth.fetch_businesses(tokens["access_token"])
96
+ if businesses.length == 1
97
+ Auth.select_business(config, businesses.first.dig("business", "id"), businesses)
98
+ biz = businesses.first["business"]
99
+ if options[:format] == "json"
100
+ puts JSON.pretty_generate({ "status" => "authenticated", "business" => biz })
101
+ else
102
+ puts "Business auto-selected: #{biz["name"]} (#{biz["id"]})"
103
+ end
104
+ else
105
+ if options[:format] == "json"
106
+ biz_list = businesses.map { |m| m["business"] }
107
+ puts JSON.pretty_generate({ "status" => "authenticated", "businesses" => biz_list, "business_selected" => false })
108
+ else
109
+ puts "Authenticated! Multiple businesses found — select one with: fb business --select ID"
110
+ businesses.each do |m|
111
+ biz = m["business"]
112
+ puts " #{biz["name"]} (ID: #{biz["id"]})"
113
+ end
114
+ end
115
+ end
116
+
117
+ when "status"
118
+ status_data = Auth.auth_status
119
+ if options[:format] == "json"
120
+ puts JSON.pretty_generate(status_data)
121
+ else
122
+ puts "Config: #{status_data["config_exists"] ? "found" : "missing"} (#{status_data["config_path"]})"
123
+ puts "Tokens: #{status_data["tokens_exist"] ? "found" : "missing"}"
124
+ if status_data["tokens_exist"]
125
+ puts "Expired: #{status_data["tokens_expired"] ? "yes" : "no"}"
126
+ end
127
+ puts "Business ID: #{status_data["business_id"] || "not set"}"
128
+ puts "Account ID: #{status_data["account_id"] || "not set"}"
129
+ end
130
+
131
+ else
132
+ unless interactive?
133
+ abort("Use auth subcommands for non-interactive auth: fb auth setup, fb auth url, fb auth callback, fb auth status")
134
+ end
135
+ config = Auth.require_config
136
+ tokens = Auth.authorize(config)
137
+ Auth.discover_business(tokens["access_token"], config)
138
+ puts "\nReady to go! Try: fb entries"
139
+ end
140
+ end
141
+
142
+ # --- business ---
143
+
144
+ desc "business", "List or select a business"
145
+ method_option :select, type: :string, desc: "Set active business by ID (omit value for interactive picker)"
146
+ def business
147
+ Auth.valid_access_token
148
+ config = Auth.load_config
149
+ tokens = Auth.load_tokens
150
+ businesses = Auth.fetch_businesses(tokens["access_token"])
151
+
152
+ if businesses.empty?
153
+ abort("No business memberships found on your FreshBooks account.")
154
+ end
155
+
156
+ if options[:select]
157
+ Auth.select_business(config, options[:select], businesses)
158
+ selected = businesses.find { |m| m.dig("business", "id").to_s == options[:select].to_s }
159
+ biz = selected["business"]
160
+ if options[:format] == "json"
161
+ puts JSON.pretty_generate(biz)
162
+ else
163
+ puts "Active business: #{biz["name"]} (#{biz["id"]})"
164
+ end
165
+ return
166
+ end
167
+
168
+ if options.key?("select") && options[:select].nil?
169
+ # --select with no value: interactive picker
170
+ unless interactive?
171
+ abort("Non-interactive: use --select ID. Available businesses:\n" +
172
+ businesses.map { |m| " #{m.dig("business", "name")} (ID: #{m.dig("business", "id")})" }.join("\n"))
173
+ end
174
+
175
+ puts "\nBusinesses:\n\n"
176
+ businesses.each_with_index do |m, i|
177
+ biz = m["business"]
178
+ active = biz["id"].to_s == config["business_id"].to_s ? " [active]" : ""
179
+ puts " #{i + 1}. #{biz["name"]} (#{biz["id"]})#{active}"
180
+ end
181
+
182
+ print "\nSelect business (1-#{businesses.length}): "
183
+ input = $stdin.gets&.strip
184
+ abort("Cancelled.") if input.nil? || input.empty?
185
+
186
+ idx = input.to_i - 1
187
+ abort("Invalid selection.") if idx < 0 || idx >= businesses.length
188
+
189
+ selected_biz = businesses[idx]
190
+ Auth.select_business(config, selected_biz.dig("business", "id"), businesses)
191
+ puts "Active business: #{selected_biz.dig("business", "name")} (#{selected_biz.dig("business", "id")})"
192
+ return
193
+ end
194
+
195
+ # Default: list businesses
196
+ if options[:format] == "json"
197
+ biz_list = businesses.map do |m|
198
+ biz = m["business"]
199
+ biz.merge("active" => biz["id"].to_s == config["business_id"].to_s)
200
+ end
201
+ puts JSON.pretty_generate(biz_list)
202
+ else
203
+ businesses.each do |m|
204
+ biz = m["business"]
205
+ active = biz["id"].to_s == config["business_id"].to_s ? " [active]" : ""
206
+ puts "#{biz["name"]} (ID: #{biz["id"]})#{active}"
207
+ end
208
+ end
209
+ end
210
+
211
+ # --- log ---
212
+
213
+ desc "log", "Log a time entry"
214
+ method_option :client, type: :string, desc: "Pre-select client by name"
215
+ method_option :project, type: :string, desc: "Pre-select project by name"
216
+ method_option :service, type: :string, desc: "Pre-select service by name"
217
+ method_option :duration, type: :numeric, desc: "Duration in hours (e.g. 2.5)"
218
+ method_option :note, type: :string, desc: "Work description"
219
+ method_option :date, type: :string, desc: "Date (YYYY-MM-DD, defaults to today)"
220
+ method_option :yes, type: :boolean, default: false, desc: "Skip confirmation"
221
+ def log
222
+ Auth.valid_access_token
223
+ defaults = Auth.load_defaults
224
+
225
+ client = select_client(defaults)
226
+ project = select_project(client["id"], defaults)
227
+ service = select_service(defaults, project)
228
+ date = pick_date
229
+ duration_hours = pick_duration
230
+ note = pick_note
231
+
232
+ client_name = display_name(client)
233
+
234
+ unless options[:format] == "json"
235
+ puts "\n--- Time Entry Summary ---"
236
+ puts " Client: #{client_name}"
237
+ puts " Project: #{project ? project["title"] : "(none)"}"
238
+ puts " Service: #{service ? service["name"] : "(none)"}"
239
+ puts " Date: #{date}"
240
+ puts " Duration: #{duration_hours}h"
241
+ puts " Note: #{note}"
242
+ puts "--------------------------\n\n"
243
+ end
244
+
245
+ unless options[:yes]
246
+ print "Submit? (Y/n): "
247
+ answer = $stdin.gets&.strip&.downcase
248
+ abort("Cancelled.") if answer == "n"
249
+ end
250
+
251
+ entry = {
252
+ "is_logged" => true,
253
+ "duration" => (duration_hours * 3600).to_i,
254
+ "note" => note,
255
+ "started_at" => normalize_datetime(date),
256
+ "client_id" => client["id"]
257
+ }
258
+ entry["project_id"] = project["id"] if project
259
+ entry["service_id"] = service["id"] if service
260
+
261
+ result = Api.create_time_entry(entry)
262
+
263
+ if options[:format] == "json"
264
+ puts JSON.pretty_generate(result)
265
+ else
266
+ puts "Time entry created!"
267
+ end
268
+
269
+ new_defaults = { "client_id" => client["id"] }
270
+ new_defaults["project_id"] = project["id"] if project
271
+ new_defaults["service_id"] = service["id"] if service
272
+ Auth.save_defaults(new_defaults)
273
+ end
274
+
275
+ # --- entries ---
276
+
277
+ desc "entries", "List time entries (defaults to current month)"
278
+ method_option :month, type: :numeric, desc: "Month (1-12)"
279
+ method_option :year, type: :numeric, desc: "Year"
280
+ method_option :from, type: :string, desc: "Start date (YYYY-MM-DD)"
281
+ method_option :to, type: :string, desc: "End date (YYYY-MM-DD)"
282
+ def entries
283
+ Auth.valid_access_token
284
+
285
+ today = Date.today
286
+
287
+ if options[:from] || options[:to]
288
+ first_day = options[:from] ? Date.parse(options[:from]) : nil
289
+ last_day = options[:to] ? Date.parse(options[:to]) : nil
290
+ else
291
+ month = options[:month] || today.month
292
+ year = options[:year] || today.year
293
+ first_day = Date.new(year, month, 1)
294
+ last_day = Date.new(year, month, -1)
295
+ end
296
+
297
+ label = if first_day && last_day
298
+ "#{first_day} to #{last_day}"
299
+ elsif first_day
300
+ "from #{first_day} onwards"
301
+ elsif last_day
302
+ "up to #{last_day}"
303
+ end
304
+
305
+ entries = Spinner.spin("Fetching time entries#{label ? " (#{label})" : ""}") do
306
+ Api.fetch_time_entries(
307
+ started_from: first_day&.to_s,
308
+ started_to: last_day&.to_s
309
+ )
310
+ end
311
+
312
+ if entries.empty?
313
+ if options[:format] == "json"
314
+ puts "[]"
315
+ else
316
+ puts "No time entries#{label ? " #{label}" : ""}."
317
+ end
318
+ return
319
+ end
320
+
321
+ if options[:format] == "json"
322
+ puts JSON.pretty_generate(entries)
323
+ return
324
+ end
325
+
326
+ maps = Spinner.spin("Resolving names") { Api.build_name_maps }
327
+ entries.sort_by! { |e| e["started_at"] || "" }
328
+
329
+ rows = entries.map do |e|
330
+ date = (e["local_started_at"] || e["started_at"] || "?").slice(0, 10)
331
+ client = maps[:clients][e["client_id"].to_s] || e["client_id"].to_s
332
+ project = maps[:projects][e["project_id"].to_s] || "-"
333
+ service = maps[:services][e["service_id"].to_s] || "-"
334
+ note = e["note"] || ""
335
+ hours = (e["duration"].to_i / 3600.0).round(2)
336
+ [e["id"].to_s, date, client, project, service, note, "#{hours}h"]
337
+ end
338
+
339
+ print_table(["ID", "Date", "Client", "Project", "Service", "Note", "Duration"], rows, wrap_col: 5)
340
+
341
+ total = entries.sum { |e| e["duration"].to_i } / 3600.0
342
+
343
+ # Per-client breakdown
344
+ by_client = entries.group_by { |e| maps[:clients][e["client_id"].to_s] || e["client_id"].to_s }
345
+ if by_client.length > 1
346
+ puts "\nBy client:"
347
+ by_client.sort_by { |_, es| -es.sum { |e| e["duration"].to_i } }.each do |name, es|
348
+ puts " #{name}: #{(es.sum { |e| e["duration"].to_i } / 3600.0).round(2)}h"
349
+ end
350
+ end
351
+
352
+ # Per-service breakdown
353
+ by_service = entries.group_by { |e| maps[:services][e["service_id"].to_s] || "-" }
354
+ if by_service.length > 1
355
+ puts "\nBy service:"
356
+ by_service.sort_by { |_, es| -es.sum { |e| e["duration"].to_i } }.each do |name, es|
357
+ puts " #{name}: #{(es.sum { |e| e["duration"].to_i } / 3600.0).round(2)}h"
358
+ end
359
+ end
360
+
361
+ puts "\nTotal: #{total.round(2)}h"
362
+ end
363
+
364
+ # --- clients ---
365
+
366
+ desc "clients", "List all clients"
367
+ def clients
368
+ Auth.valid_access_token
369
+ clients = Spinner.spin("Fetching clients") { Api.fetch_clients }
370
+
371
+ if clients.empty?
372
+ puts "No clients found."
373
+ return
374
+ end
375
+
376
+ if options[:format] == "json"
377
+ puts JSON.pretty_generate(clients)
378
+ return
379
+ end
380
+
381
+ rows = clients.map do |c|
382
+ name = c["organization"]
383
+ name = "#{c["fname"]} #{c["lname"]}" if name.nil? || name.empty?
384
+ email = c["email"] || "-"
385
+ org = c["organization"] || "-"
386
+ [name, email, org]
387
+ end
388
+
389
+ print_table(["Name", "Email", "Organization"], rows)
390
+ end
391
+
392
+ # --- projects ---
393
+
394
+ desc "projects", "List all projects"
395
+ method_option :client, type: :string, desc: "Filter by client name"
396
+ def projects
397
+ Auth.valid_access_token
398
+ maps = Spinner.spin("Resolving names") { Api.build_name_maps }
399
+
400
+ projects = if options[:client]
401
+ client_id = maps[:clients].find { |_id, name| name.downcase == options[:client].downcase }&.first
402
+ abort("Client not found: #{options[:client]}") unless client_id
403
+ Spinner.spin("Fetching projects") { Api.fetch_projects_for_client(client_id) }
404
+ else
405
+ Spinner.spin("Fetching projects") { Api.fetch_projects }
406
+ end
407
+
408
+ if projects.empty?
409
+ puts "No projects found."
410
+ return
411
+ end
412
+
413
+ if options[:format] == "json"
414
+ puts JSON.pretty_generate(projects)
415
+ return
416
+ end
417
+
418
+ rows = projects.map do |p|
419
+ client_name = maps[:clients][p["client_id"].to_s] || "-"
420
+ [p["title"], client_name, p["active"] ? "active" : "inactive"]
421
+ end
422
+
423
+ print_table(["Title", "Client", "Status"], rows)
424
+ end
425
+
426
+ # --- services ---
427
+
428
+ desc "services", "List all services"
429
+ def services
430
+ Auth.valid_access_token
431
+ services = Spinner.spin("Fetching services") { Api.fetch_services }
432
+
433
+ if services.empty?
434
+ puts "No services found."
435
+ return
436
+ end
437
+
438
+ if options[:format] == "json"
439
+ puts JSON.pretty_generate(services)
440
+ return
441
+ end
442
+
443
+ rows = services.map do |s|
444
+ billable = s["billable"] ? "yes" : "no"
445
+ [s["name"], billable]
446
+ end
447
+
448
+ print_table(["Name", "Billable"], rows)
449
+ end
450
+
451
+ # --- status ---
452
+
453
+ desc "status", "Show hours summary for today, this week, and this month"
454
+ def status
455
+ Auth.valid_access_token
456
+ today = Date.today
457
+ week_start = today - ((today.wday - 1) % 7)
458
+ month_start = Date.new(today.year, today.month, 1)
459
+
460
+ entries = Spinner.spin("Fetching time entries") do
461
+ Api.fetch_time_entries(started_from: month_start.to_s, started_to: today.to_s)
462
+ end
463
+ maps = Spinner.spin("Resolving names") { Api.build_name_maps }
464
+
465
+ today_entries = entries.select { |e| e["started_at"] == today.to_s }
466
+ week_entries = entries.select { |e| d = e["started_at"]; d && d >= week_start.to_s && d <= today.to_s }
467
+ month_entries = entries
468
+
469
+ if options[:format] == "json"
470
+ puts JSON.pretty_generate({
471
+ "today" => build_status_data(today.to_s, today.to_s, today_entries, maps),
472
+ "this_week" => build_status_data(week_start.to_s, today.to_s, week_entries, maps),
473
+ "this_month" => build_status_data(month_start.to_s, today.to_s, month_entries, maps)
474
+ })
475
+ return
476
+ end
477
+
478
+ print_status_section("Today (#{today})", today_entries, maps)
479
+ print_status_section("This Week (#{week_start} to #{today})", week_entries, maps)
480
+ print_status_section("This Month (#{month_start} to #{today})", month_entries, maps)
481
+ end
482
+
483
+ # --- delete ---
484
+
485
+ desc "delete", "Delete a time entry"
486
+ method_option :id, type: :numeric, desc: "Time entry ID (skip interactive picker)"
487
+ method_option :yes, type: :boolean, default: false, desc: "Skip confirmation"
488
+ def delete
489
+ Auth.valid_access_token
490
+
491
+ if options[:id]
492
+ entry_id = options[:id]
493
+ else
494
+ abort("Missing required flag: --id") unless interactive?
495
+ entry_id = pick_entry_interactive("delete")
496
+ end
497
+
498
+ unless options[:yes]
499
+ print "Delete entry #{entry_id}? (y/N): "
500
+ answer = $stdin.gets&.strip&.downcase
501
+ abort("Cancelled.") unless answer == "y"
502
+ end
503
+
504
+ Spinner.spin("Deleting time entry") { Api.delete_time_entry(entry_id) }
505
+
506
+ if options[:format] == "json"
507
+ puts JSON.pretty_generate({ "id" => entry_id, "deleted" => true })
508
+ else
509
+ puts "Time entry #{entry_id} deleted."
510
+ end
511
+ end
512
+
513
+ # --- edit ---
514
+
515
+ desc "edit", "Edit a time entry"
516
+ method_option :id, type: :numeric, desc: "Time entry ID (skip interactive picker)"
517
+ method_option :duration, type: :numeric, desc: "New duration in hours"
518
+ method_option :note, type: :string, desc: "New note"
519
+ method_option :date, type: :string, desc: "New date (YYYY-MM-DD)"
520
+ method_option :client, type: :string, desc: "New client name"
521
+ method_option :project, type: :string, desc: "New project name"
522
+ method_option :service, type: :string, desc: "New service name"
523
+ method_option :yes, type: :boolean, default: false, desc: "Skip confirmation"
524
+ def edit
525
+ Auth.valid_access_token
526
+
527
+ if options[:id]
528
+ entry_id = options[:id]
529
+ else
530
+ abort("Missing required flag: --id") unless interactive?
531
+ entry_id = pick_entry_interactive("edit")
532
+ end
533
+
534
+ entry = Spinner.spin("Fetching time entry") { Api.fetch_time_entry(entry_id) }
535
+ abort("Time entry not found.") unless entry
536
+
537
+ maps = Spinner.spin("Resolving names") { Api.build_name_maps }
538
+ has_edit_flags = options[:duration] || options[:note] || options[:date] || options[:client] || options[:project] || options[:service]
539
+ scripted = has_edit_flags || !interactive?
540
+
541
+ fields = build_edit_fields(entry, maps, scripted)
542
+
543
+ current_client = maps[:clients][entry["client_id"].to_s] || entry["client_id"].to_s
544
+ current_project = maps[:projects][entry["project_id"].to_s] || "-"
545
+ current_hours = (entry["duration"].to_i / 3600.0).round(2)
546
+ new_hours = fields["duration"] ? (fields["duration"].to_i / 3600.0).round(2) : current_hours
547
+
548
+ unless options[:format] == "json"
549
+ puts "\n--- Edit Summary ---"
550
+ puts " Date: #{fields["started_at"] || entry["started_at"]}"
551
+ puts " Client: #{fields["client_id"] ? maps[:clients][fields["client_id"].to_s] : current_client}"
552
+ puts " Project: #{fields["project_id"] ? maps[:projects][fields["project_id"].to_s] : current_project}"
553
+ puts " Duration: #{new_hours}h"
554
+ puts " Note: #{fields["note"] || entry["note"]}"
555
+ puts "--------------------\n\n"
556
+ end
557
+
558
+ unless options[:yes]
559
+ print "Save changes? (Y/n): "
560
+ answer = $stdin.gets&.strip&.downcase
561
+ abort("Cancelled.") if answer == "n"
562
+ end
563
+
564
+ result = Spinner.spin("Updating time entry") { Api.update_time_entry(entry_id, fields) }
565
+
566
+ if options[:format] == "json"
567
+ puts JSON.pretty_generate(result)
568
+ else
569
+ puts "Time entry #{entry_id} updated."
570
+ end
571
+ end
572
+
573
+ # --- cache ---
574
+
575
+ desc "cache SUBCOMMAND", "Manage cached data (refresh, clear, status)"
576
+ def cache(subcommand = "status")
577
+ case subcommand
578
+ when "refresh"
579
+ Auth.valid_access_token
580
+ Spinner.spin("Refreshing cache") do
581
+ Api.fetch_clients(force: true)
582
+ Api.fetch_projects(force: true)
583
+ Api.fetch_services(force: true)
584
+ Api.build_name_maps
585
+ end
586
+ puts "Cache refreshed."
587
+ when "clear"
588
+ if File.exist?(Auth.cache_path)
589
+ File.delete(Auth.cache_path)
590
+ puts "Cache cleared."
591
+ else
592
+ puts "No cache file found."
593
+ end
594
+ when "status"
595
+ cache = Auth.load_cache
596
+ if options[:format] == "json"
597
+ if cache["updated_at"]
598
+ age = Time.now.to_i - cache["updated_at"]
599
+ puts JSON.pretty_generate({
600
+ "updated_at" => cache["updated_at"],
601
+ "age_seconds" => age,
602
+ "fresh" => age < 600,
603
+ "clients" => (cache["clients_data"] || []).length,
604
+ "projects" => (cache["projects_data"] || []).length,
605
+ "services" => (cache["services"] || cache["services_data"] || {}).length
606
+ })
607
+ else
608
+ puts JSON.pretty_generate({ "fresh" => false, "clients" => 0, "projects" => 0, "services" => 0 })
609
+ end
610
+ return
611
+ end
612
+
613
+ if cache["updated_at"]
614
+ age = Time.now.to_i - cache["updated_at"]
615
+ updated = Time.at(cache["updated_at"]).strftime("%Y-%m-%d %H:%M:%S")
616
+ fresh = age < 600
617
+ puts "Cache updated: #{updated}"
618
+ puts "Age: #{age}s (#{fresh ? "fresh" : "stale"})"
619
+ puts "Clients: #{(cache["clients_data"] || []).length}"
620
+ puts "Projects: #{(cache["projects_data"] || []).length}"
621
+ puts "Services: #{(cache["services"] || cache["services_data"] || {}).length}"
622
+ else
623
+ puts "No cache data."
624
+ end
625
+ else
626
+ abort("Unknown cache subcommand: #{subcommand}. Use: refresh, clear, status")
627
+ end
628
+ end
629
+
630
+ # --- help ---
631
+
632
+ desc "help [COMMAND]", "Describe available commands or one specific command"
633
+ def help(command = nil)
634
+ if options[:format] == "json"
635
+ puts JSON.pretty_generate(help_json)
636
+ return
637
+ end
638
+ super
639
+ end
640
+
641
+ private
642
+
643
+ def interactive?
644
+ return false if options[:no_interactive]
645
+ return true if options[:interactive]
646
+ $stdin.tty?
647
+ end
648
+
649
+ def select_client(defaults)
650
+ clients = Spinner.spin("Fetching clients") { Api.fetch_clients }
651
+
652
+ if options[:client]
653
+ match = clients.find { |c| display_name(c).downcase == options[:client].downcase }
654
+ abort("Client not found: #{options[:client]}") unless match
655
+ return match
656
+ end
657
+
658
+ abort("No clients found.") if clients.empty?
659
+
660
+ unless interactive?
661
+ # Non-interactive: auto-select if single, abort with list if multiple
662
+ default_client = clients.find { |c| c["id"].to_i == defaults["client_id"].to_i }
663
+ return default_client if default_client
664
+ return clients.first if clients.length == 1
665
+ names = clients.map { |c| display_name(c) }.join(", ")
666
+ abort("Multiple clients found. Use --client to specify: #{names}")
667
+ end
668
+
669
+ puts "\nClients:\n\n"
670
+ clients.each_with_index do |c, i|
671
+ name = display_name(c)
672
+ default_marker = c["id"].to_i == defaults["client_id"].to_i ? " [default]" : ""
673
+ puts " #{i + 1}. #{name}#{default_marker}"
674
+ end
675
+
676
+ default_idx = clients.index { |c| c["id"].to_i == defaults["client_id"].to_i }
677
+ prompt = default_idx ? "\nSelect client (1-#{clients.length}) [#{default_idx + 1}]: " : "\nSelect client (1-#{clients.length}): "
678
+ print prompt
679
+ input = $stdin.gets&.strip
680
+
681
+ idx = if input.nil? || input.empty?
682
+ default_idx || 0
683
+ else
684
+ input.to_i - 1
685
+ end
686
+
687
+ abort("Invalid selection.") if idx < 0 || idx >= clients.length
688
+ clients[idx]
689
+ end
690
+
691
+ def select_project(client_id, defaults)
692
+ projects = Spinner.spin("Fetching projects") { Api.fetch_projects_for_client(client_id) }
693
+
694
+ if options[:project]
695
+ match = projects.find { |p| p["title"].downcase == options[:project].downcase }
696
+ abort("Project not found: #{options[:project]}") unless match
697
+ return match
698
+ end
699
+
700
+ return nil if projects.empty?
701
+
702
+ unless interactive?
703
+ # Non-interactive: auto-select if single, return nil if multiple (optional)
704
+ default_project = projects.find { |p| p["id"].to_i == defaults["project_id"].to_i }
705
+ return default_project if default_project
706
+ return projects.first if projects.length == 1
707
+ return nil
708
+ end
709
+
710
+ puts "\nProjects:\n\n"
711
+ projects.each_with_index do |p, i|
712
+ default_marker = p["id"].to_i == defaults["project_id"].to_i ? " [default]" : ""
713
+ puts " #{i + 1}. #{p["title"]}#{default_marker}"
714
+ end
715
+
716
+ default_idx = projects.index { |p| p["id"].to_i == defaults["project_id"].to_i }
717
+ prompt = default_idx ? "\nSelect project (1-#{projects.length}, Enter to skip) [#{default_idx + 1}]: " : "\nSelect project (1-#{projects.length}, Enter to skip): "
718
+ print prompt
719
+ input = $stdin.gets&.strip
720
+
721
+ if input.nil? || input.empty?
722
+ return default_idx ? projects[default_idx] : nil
723
+ end
724
+
725
+ idx = input.to_i - 1
726
+ return nil if idx < 0 || idx >= projects.length
727
+ projects[idx]
728
+ end
729
+
730
+ def select_service(defaults, project = nil)
731
+ # Use project-scoped services if available, fall back to global
732
+ services = if project && project["services"] && !project["services"].empty?
733
+ project["services"]
734
+ else
735
+ Spinner.spin("Fetching services") { Api.fetch_services }
736
+ end
737
+
738
+ if options[:service]
739
+ match = services.find { |s| s["name"].downcase == options[:service].downcase }
740
+ abort("Service not found: #{options[:service]}") unless match
741
+ return match
742
+ end
743
+
744
+ unless interactive?
745
+ # Non-interactive: auto-select if single, use default if set, otherwise skip
746
+ default_service = services.find { |s| s["id"].to_i == defaults["service_id"].to_i }
747
+ return default_service if default_service
748
+ return services.first if services.length == 1
749
+ return nil
750
+ end
751
+
752
+ return nil if services.empty?
753
+
754
+ puts "\nServices:\n\n"
755
+ services.each_with_index do |s, i|
756
+ default_marker = s["id"].to_i == defaults["service_id"].to_i ? " [default]" : ""
757
+ puts " #{i + 1}. #{s["name"]}#{default_marker}"
758
+ end
759
+
760
+ default_idx = services.index { |s| s["id"].to_i == defaults["service_id"].to_i }
761
+ prompt = default_idx ? "\nSelect service (1-#{services.length}, Enter to skip) [#{default_idx + 1}]: " : "\nSelect service (1-#{services.length}, Enter to skip): "
762
+ print prompt
763
+ input = $stdin.gets&.strip
764
+
765
+ if input.nil? || input.empty?
766
+ return default_idx ? services[default_idx] : nil
767
+ end
768
+
769
+ idx = input.to_i - 1
770
+ return nil if idx < 0 || idx >= services.length
771
+ services[idx]
772
+ end
773
+
774
+ def pick_date
775
+ return options[:date] if options[:date]
776
+
777
+ today = Date.today.to_s
778
+ return today unless interactive?
779
+
780
+ print "\nDate [#{today}]: "
781
+ input = $stdin.gets&.strip
782
+ (input.nil? || input.empty?) ? today : input
783
+ end
784
+
785
+ def pick_duration
786
+ return options[:duration] if options[:duration]
787
+
788
+ abort("Missing required flag: --duration") unless interactive?
789
+
790
+ print "\nDuration (hours): "
791
+ input = $stdin.gets&.strip
792
+ abort("Duration is required.") if input.nil? || input.empty?
793
+ input.to_f
794
+ end
795
+
796
+ def pick_note
797
+ return options[:note] if options[:note]
798
+
799
+ abort("Missing required flag: --note") unless interactive?
800
+
801
+ print "\nNote: "
802
+ input = $stdin.gets&.strip
803
+ abort("Note is required.") if input.nil? || input.empty?
804
+ input
805
+ end
806
+
807
+ def print_table(headers, rows, wrap_col: nil)
808
+ widths = headers.each_with_index.map do |h, i|
809
+ [h.length, *rows.map { |r| r[i].to_s.length }].max
810
+ end
811
+
812
+ # Word-wrap a specific column if it would exceed terminal width
813
+ if wrap_col
814
+ term_width = IO.console&.winsize&.last || ENV["COLUMNS"]&.to_i || 120
815
+ fixed_width = widths.each_with_index.sum { |w, i| i == wrap_col ? 0 : w } + (widths.length - 1) * 2
816
+ max_wrap = term_width - fixed_width
817
+ max_wrap = [max_wrap, 20].max
818
+ widths[wrap_col] = [widths[wrap_col], max_wrap].min
819
+ end
820
+
821
+ fmt = widths.map { |w| "%-#{w}s" }.join(" ")
822
+ puts fmt % headers
823
+ puts widths.map { |w| "-" * w }.join(" ")
824
+
825
+ rows.each do |r|
826
+ if wrap_col && r[wrap_col].to_s.length > widths[wrap_col]
827
+ lines = word_wrap(r[wrap_col].to_s, widths[wrap_col])
828
+ padded = widths.each_with_index.map { |w, i| i == wrap_col ? "" : " " * w }
829
+ pad_fmt = padded.each_with_index.map { |p, i| i == wrap_col ? "%s" : "%-#{widths[i]}s" }.join(" ")
830
+ lines.each_with_index do |line, li|
831
+ if li == 0
832
+ row = r.dup
833
+ row[wrap_col] = line
834
+ puts fmt % row
835
+ else
836
+ blank = padded.dup
837
+ blank[wrap_col] = line
838
+ puts pad_fmt % blank
839
+ end
840
+ end
841
+ else
842
+ puts fmt % r
843
+ end
844
+ end
845
+ end
846
+
847
+ def word_wrap(text, width)
848
+ lines = []
849
+ remaining = text
850
+ while remaining.length > width
851
+ break_at = remaining.rindex(" ", width) || width
852
+ lines << remaining[0...break_at]
853
+ remaining = remaining[break_at..].lstrip
854
+ end
855
+ lines << remaining unless remaining.empty?
856
+ lines
857
+ end
858
+
859
+ def print_status_section(title, entries, maps)
860
+ puts "\n#{title}"
861
+ if entries.empty?
862
+ puts " No entries."
863
+ return
864
+ end
865
+
866
+ grouped = {}
867
+ entries.each do |e|
868
+ client = maps[:clients][e["client_id"].to_s] || e["client_id"].to_s
869
+ project = maps[:projects][e["project_id"].to_s] || "-"
870
+ key = "#{client} / #{project}"
871
+ grouped[key] ||= 0.0
872
+ grouped[key] += e["duration"].to_i / 3600.0
873
+ end
874
+
875
+ grouped.each do |key, hours|
876
+ puts " #{key}: #{hours.round(2)}h"
877
+ end
878
+
879
+ total = entries.sum { |e| e["duration"].to_i } / 3600.0
880
+ puts " Total: #{total.round(2)}h"
881
+ end
882
+
883
+ def build_status_data(from, to, entries, maps)
884
+ entry_data = entries.map do |e|
885
+ {
886
+ "id" => e["id"],
887
+ "client" => maps[:clients][e["client_id"].to_s] || e["client_id"].to_s,
888
+ "project" => maps[:projects][e["project_id"].to_s] || "-",
889
+ "duration" => e["duration"],
890
+ "hours" => (e["duration"].to_i / 3600.0).round(2),
891
+ "note" => e["note"],
892
+ "started_at" => e["started_at"]
893
+ }
894
+ end
895
+ total = entries.sum { |e| e["duration"].to_i } / 3600.0
896
+ { "from" => from, "to" => to, "entries" => entry_data, "total_hours" => total.round(2) }
897
+ end
898
+
899
+ def pick_entry_interactive(action)
900
+ today = Date.today.to_s
901
+ entries = Spinner.spin("Fetching today's entries") do
902
+ Api.fetch_time_entries(started_from: today, started_to: today)
903
+ end
904
+ abort("No entries found for today.") if entries.empty?
905
+
906
+ maps = Spinner.spin("Resolving names") { Api.build_name_maps }
907
+
908
+ puts "\nToday's entries:\n\n"
909
+ entries.each_with_index do |e, i|
910
+ client = maps[:clients][e["client_id"].to_s] || e["client_id"].to_s
911
+ hours = (e["duration"].to_i / 3600.0).round(2)
912
+ note = (e["note"] || "").slice(0, 40)
913
+ puts " #{i + 1}. [#{e["id"]}] #{client} — #{hours}h — #{note}"
914
+ end
915
+
916
+ print "\nSelect entry to #{action} (1-#{entries.length}): "
917
+ input = $stdin.gets&.strip
918
+ abort("Cancelled.") if input.nil? || input.empty?
919
+
920
+ idx = input.to_i - 1
921
+ abort("Invalid selection.") if idx < 0 || idx >= entries.length
922
+ entries[idx]["id"]
923
+ end
924
+
925
+ def build_edit_fields(entry, maps, scripted)
926
+ # FreshBooks API replaces the entry — always include all current fields
927
+ fields = {
928
+ "started_at" => entry["started_at"],
929
+ "is_logged" => entry["is_logged"] || true,
930
+ "duration" => entry["duration"],
931
+ "note" => entry["note"],
932
+ "client_id" => entry["client_id"],
933
+ "project_id" => entry["project_id"],
934
+ "service_id" => entry["service_id"]
935
+ }
936
+
937
+ if scripted
938
+ fields["duration"] = (options[:duration] * 3600).to_i if options[:duration]
939
+ fields["note"] = options[:note] if options[:note]
940
+ fields["started_at"] = normalize_datetime(options[:date]) if options[:date]
941
+
942
+ if options[:client]
943
+ client_id = maps[:clients].find { |_id, name| name.downcase == options[:client].downcase }&.first
944
+ abort("Client not found: #{options[:client]}") unless client_id
945
+ fields["client_id"] = client_id.to_i
946
+ end
947
+
948
+ if options[:project]
949
+ project_id = maps[:projects].find { |_id, name| name.downcase == options[:project].downcase }&.first
950
+ abort("Project not found: #{options[:project]}") unless project_id
951
+ fields["project_id"] = project_id.to_i
952
+ end
953
+
954
+ if options[:service]
955
+ service_id = maps[:services].find { |_id, name| name.downcase == options[:service].downcase }&.first
956
+ abort("Service not found: #{options[:service]}") unless service_id
957
+ fields["service_id"] = service_id.to_i
958
+ end
959
+ else
960
+ current_hours = (entry["duration"].to_i / 3600.0).round(2)
961
+ print "\nDuration (hours) [#{current_hours}]: "
962
+ input = $stdin.gets&.strip
963
+ fields["duration"] = (input.to_f * 3600).to_i unless input.nil? || input.empty?
964
+
965
+ current_note = entry["note"] || ""
966
+ print "Note [#{current_note}]: "
967
+ input = $stdin.gets&.strip
968
+ fields["note"] = input unless input.nil? || input.empty?
969
+
970
+ current_date = entry["started_at"] || ""
971
+ print "Date [#{current_date}]: "
972
+ input = $stdin.gets&.strip
973
+ fields["started_at"] = input unless input.nil? || input.empty?
974
+ end
975
+
976
+ fields
977
+ end
978
+
979
+ def normalize_datetime(date_str)
980
+ return date_str if date_str.include?("T")
981
+ "#{date_str}T00:00:00Z"
982
+ end
983
+
984
+ def display_name(client)
985
+ name = client["organization"]
986
+ (name.nil? || name.empty?) ? "#{client["fname"]} #{client["lname"]}" : name
987
+ end
988
+
989
+ def help_json
990
+ {
991
+ name: "fb",
992
+ description: "FreshBooks time tracking CLI",
993
+ required_scopes: Auth::REQUIRED_SCOPES,
994
+ global_flags: {
995
+ "--no-interactive" => "Disable interactive prompts (auto-detected when not a TTY)",
996
+ "--format json" => "Output format: json (available on all commands)",
997
+ "--dry-run" => "Simulate command without making network calls (writes skipped)"
998
+ },
999
+ commands: {
1000
+ auth: {
1001
+ description: "Authenticate with FreshBooks via OAuth2",
1002
+ usage: "fb auth [SUBCOMMAND]",
1003
+ interactive: "Interactive when no subcommand; subcommands are non-interactive",
1004
+ subcommands: {
1005
+ "setup" => "Save OAuth credentials from env vars: FRESHBOOKS_CLIENT_ID, FRESHBOOKS_CLIENT_SECRET (or ~/.fb/.env)",
1006
+ "url" => "Print the OAuth authorization URL",
1007
+ "callback" => "Exchange OAuth code: fb auth callback REDIRECT_URL",
1008
+ "status" => "Show current auth state (config, tokens, business)"
1009
+ },
1010
+ flags: {}
1011
+ },
1012
+ business: {
1013
+ description: "List or select a business",
1014
+ usage: "fb business [--select ID]",
1015
+ interactive: "Interactive with --select (no value); non-interactive with --select ID",
1016
+ flags: {
1017
+ "--select ID" => "Set active business by ID",
1018
+ "--select" => "Interactive business picker (no value)"
1019
+ }
1020
+ },
1021
+ log: {
1022
+ description: "Log a time entry",
1023
+ usage: "fb log",
1024
+ interactive: "Prompts for missing fields when interactive; requires --duration and --note when non-interactive",
1025
+ flags: {
1026
+ "--client" => "Client name (required non-interactive if multiple clients, auto-selected if single)",
1027
+ "--project" => "Project name (optional, auto-selected if single)",
1028
+ "--service" => "Service name (optional)",
1029
+ "--duration" => "Duration in hours, e.g. 2.5 (required non-interactive)",
1030
+ "--note" => "Work description (required non-interactive)",
1031
+ "--date" => "Date YYYY-MM-DD (defaults to today)",
1032
+ "--yes" => "Skip confirmation prompt"
1033
+ }
1034
+ },
1035
+ entries: {
1036
+ description: "List time entries (defaults to current month)",
1037
+ usage: "fb entries",
1038
+ flags: {
1039
+ "--from" => "Start date (YYYY-MM-DD, open-ended if omitted)",
1040
+ "--to" => "End date (YYYY-MM-DD, open-ended if omitted)",
1041
+ "--month" => "Month (1-12, defaults to current)",
1042
+ "--year" => "Year (defaults to current)"
1043
+ }
1044
+ },
1045
+ clients: {
1046
+ description: "List all clients",
1047
+ usage: "fb clients"
1048
+ },
1049
+ projects: {
1050
+ description: "List all projects",
1051
+ usage: "fb projects",
1052
+ flags: {
1053
+ "--client" => "Filter by client name"
1054
+ }
1055
+ },
1056
+ services: {
1057
+ description: "List all services",
1058
+ usage: "fb services"
1059
+ },
1060
+ status: {
1061
+ description: "Show hours summary for today, this week, and this month",
1062
+ usage: "fb status"
1063
+ },
1064
+ delete: {
1065
+ description: "Delete a time entry",
1066
+ usage: "fb delete --id ID --yes",
1067
+ interactive: "Interactive picker when no --id; requires --id when non-interactive",
1068
+ flags: {
1069
+ "--id" => "Time entry ID (required non-interactive)",
1070
+ "--yes" => "Skip confirmation prompt"
1071
+ }
1072
+ },
1073
+ edit: {
1074
+ description: "Edit a time entry",
1075
+ usage: "fb edit --id ID [--duration H] [--note TEXT] --yes",
1076
+ interactive: "Interactive picker and field editor when no --id; requires --id when non-interactive",
1077
+ flags: {
1078
+ "--id" => "Time entry ID (required non-interactive)",
1079
+ "--duration" => "New duration in hours",
1080
+ "--note" => "New note",
1081
+ "--date" => "New date (YYYY-MM-DD)",
1082
+ "--client" => "New client name",
1083
+ "--project" => "New project name",
1084
+ "--service" => "New service name",
1085
+ "--yes" => "Skip confirmation prompt"
1086
+ }
1087
+ },
1088
+ cache: {
1089
+ description: "Manage cached data",
1090
+ usage: "fb cache [refresh|clear|status]",
1091
+ subcommands: {
1092
+ "refresh" => "Force-refresh all cached data",
1093
+ "clear" => "Delete cache file",
1094
+ "status" => "Show cache age and staleness"
1095
+ }
1096
+ },
1097
+ help: {
1098
+ description: "Show help information",
1099
+ usage: "fb help [COMMAND]"
1100
+ }
1101
+ }
1102
+ }
1103
+ end
1104
+ end # closes class Commands
1105
+ end # closes module CLI
1106
+ end # closes module FreshBooks