freshbooks-cli 0.3.3 → 0.5.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,1180 @@
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 :internal, type: :boolean, default: false, desc: "Log to an internal project with no client"
221
+ method_option :yes, type: :boolean, default: false, desc: "Skip confirmation"
222
+ def log
223
+ Auth.valid_access_token
224
+ defaults = Auth.load_defaults
225
+
226
+ context = resolve_log_context(defaults)
227
+ client = context[:client]
228
+ project = context[:project]
229
+ service = select_service(defaults, project)
230
+ date = pick_date
231
+ duration_hours = pick_duration
232
+ note = pick_note
233
+
234
+ client_name = client ? display_name(client) : "Internal"
235
+
236
+ unless options[:format] == "json"
237
+ puts "\n--- Time Entry Summary ---"
238
+ puts " Client: #{client_name}"
239
+ puts " Project: #{project ? project["title"] : "(none)"}"
240
+ puts " Service: #{service ? service["name"] : "(none)"}"
241
+ puts " Date: #{date}"
242
+ puts " Duration: #{duration_hours}h"
243
+ puts " Note: #{note}"
244
+ puts "--------------------------\n\n"
245
+ end
246
+
247
+ unless options[:yes]
248
+ print "Submit? (Y/n): "
249
+ answer = $stdin.gets&.strip&.downcase
250
+ abort("Cancelled.") if answer == "n"
251
+ end
252
+
253
+ entry = {
254
+ "is_logged" => true,
255
+ "duration" => (duration_hours * 3600).to_i,
256
+ "note" => note,
257
+ "started_at" => normalize_datetime(date)
258
+ }
259
+ entry["client_id"] = client["id"] if client
260
+ entry["project_id"] = project["id"] if project
261
+ entry["service_id"] = service["id"] if service
262
+
263
+ result = Api.create_time_entry(entry)
264
+
265
+ if options[:format] == "json"
266
+ puts JSON.pretty_generate(result)
267
+ else
268
+ puts "Time entry created!"
269
+ end
270
+
271
+ new_defaults = {}
272
+ new_defaults["client_id"] = client["id"] if client
273
+ new_defaults["project_id"] = project["id"] if project
274
+ new_defaults["service_id"] = service["id"] if service
275
+ Auth.save_defaults(new_defaults)
276
+ end
277
+
278
+ # --- entries ---
279
+
280
+ desc "entries", "List time entries (defaults to current month)"
281
+ method_option :month, type: :numeric, desc: "Month (1-12)"
282
+ method_option :year, type: :numeric, desc: "Year"
283
+ method_option :from, type: :string, desc: "Start date (YYYY-MM-DD)"
284
+ method_option :to, type: :string, desc: "End date (YYYY-MM-DD)"
285
+ def entries
286
+ Auth.valid_access_token
287
+
288
+ today = Date.today
289
+
290
+ if options[:from] || options[:to]
291
+ first_day = options[:from] ? Date.parse(options[:from]) : nil
292
+ last_day = options[:to] ? Date.parse(options[:to]) : nil
293
+ else
294
+ month = options[:month] || today.month
295
+ year = options[:year] || today.year
296
+ first_day = Date.new(year, month, 1)
297
+ last_day = Date.new(year, month, -1)
298
+ end
299
+
300
+ label = if first_day && last_day
301
+ "#{first_day} to #{last_day}"
302
+ elsif first_day
303
+ "from #{first_day} onwards"
304
+ elsif last_day
305
+ "up to #{last_day}"
306
+ end
307
+
308
+ entries = Spinner.spin("Fetching time entries#{label ? " (#{label})" : ""}") do
309
+ Api.fetch_time_entries(
310
+ started_from: first_day&.to_s,
311
+ started_to: last_day&.to_s
312
+ )
313
+ end
314
+
315
+ if entries.empty?
316
+ if options[:format] == "json"
317
+ puts "[]"
318
+ else
319
+ puts "No time entries#{label ? " #{label}" : ""}."
320
+ end
321
+ return
322
+ end
323
+
324
+ if options[:format] == "json"
325
+ puts JSON.pretty_generate(entries)
326
+ return
327
+ end
328
+
329
+ maps = Spinner.spin("Resolving names") { Api.build_name_maps }
330
+ entries.sort_by! { |e| e["started_at"] || "" }
331
+
332
+ rows = entries.map do |e|
333
+ date = (e["local_started_at"] || e["started_at"] || "?").slice(0, 10)
334
+ client = display_client_name(e["client_id"], maps)
335
+ project = maps[:projects][e["project_id"].to_s] || "-"
336
+ service = maps[:services][e["service_id"].to_s] || "-"
337
+ note = e["note"] || ""
338
+ hours = (e["duration"].to_i / 3600.0).round(2)
339
+ [e["id"].to_s, date, client, project, service, note, "#{hours}h"]
340
+ end
341
+
342
+ print_table(["ID", "Date", "Client", "Project", "Service", "Note", "Duration"], rows, wrap_col: 5)
343
+
344
+ total = entries.sum { |e| e["duration"].to_i } / 3600.0
345
+
346
+ # Per-client breakdown
347
+ by_client = entries.group_by { |e| display_client_name(e["client_id"], maps) }
348
+ if by_client.length > 1
349
+ puts "\nBy client:"
350
+ by_client.sort_by { |_, es| -es.sum { |e| e["duration"].to_i } }.each do |name, es|
351
+ puts " #{name}: #{(es.sum { |e| e["duration"].to_i } / 3600.0).round(2)}h"
352
+ end
353
+ end
354
+
355
+ # Per-service breakdown
356
+ by_service = entries.group_by { |e| maps[:services][e["service_id"].to_s] || "-" }
357
+ if by_service.length > 1
358
+ puts "\nBy service:"
359
+ by_service.sort_by { |_, es| -es.sum { |e| e["duration"].to_i } }.each do |name, es|
360
+ puts " #{name}: #{(es.sum { |e| e["duration"].to_i } / 3600.0).round(2)}h"
361
+ end
362
+ end
363
+
364
+ puts "\nTotal: #{total.round(2)}h"
365
+ end
366
+
367
+ # --- clients ---
368
+
369
+ desc "clients", "List all clients"
370
+ def clients
371
+ Auth.valid_access_token
372
+ clients = Spinner.spin("Fetching clients") { Api.fetch_clients }
373
+
374
+ if clients.empty?
375
+ puts "No clients found."
376
+ return
377
+ end
378
+
379
+ if options[:format] == "json"
380
+ puts JSON.pretty_generate(clients)
381
+ return
382
+ end
383
+
384
+ rows = clients.map do |c|
385
+ name = c["organization"]
386
+ name = "#{c["fname"]} #{c["lname"]}" if name.nil? || name.empty?
387
+ email = c["email"] || "-"
388
+ org = c["organization"] || "-"
389
+ [name, email, org]
390
+ end
391
+
392
+ print_table(["Name", "Email", "Organization"], rows)
393
+ end
394
+
395
+ # --- projects ---
396
+
397
+ desc "projects", "List all projects"
398
+ method_option :client, type: :string, desc: "Filter by client name"
399
+ def projects
400
+ Auth.valid_access_token
401
+ maps = Spinner.spin("Resolving names") { Api.build_name_maps }
402
+
403
+ projects = if options[:client]
404
+ client_id = maps[:clients].find { |_id, name| name.downcase == options[:client].downcase }&.first
405
+ abort("Client not found: #{options[:client]}") unless client_id
406
+ Spinner.spin("Fetching projects") { Api.fetch_projects_for_client(client_id) }
407
+ else
408
+ Spinner.spin("Fetching projects") { Api.fetch_projects }
409
+ end
410
+
411
+ if projects.empty?
412
+ puts "No projects found."
413
+ return
414
+ end
415
+
416
+ if options[:format] == "json"
417
+ puts JSON.pretty_generate(projects)
418
+ return
419
+ end
420
+
421
+ rows = projects.map do |p|
422
+ client_name = display_project_client_name(p, maps)
423
+ [p["title"], client_name, p["active"] ? "active" : "inactive"]
424
+ end
425
+
426
+ print_table(["Title", "Client", "Status"], rows)
427
+ end
428
+
429
+ # --- services ---
430
+
431
+ desc "services", "List all services"
432
+ def services
433
+ Auth.valid_access_token
434
+ services = Spinner.spin("Fetching services") { Api.fetch_services }
435
+
436
+ if services.empty?
437
+ puts "No services found."
438
+ return
439
+ end
440
+
441
+ if options[:format] == "json"
442
+ puts JSON.pretty_generate(services)
443
+ return
444
+ end
445
+
446
+ rows = services.map do |s|
447
+ billable = s["billable"] ? "yes" : "no"
448
+ [s["name"], billable]
449
+ end
450
+
451
+ print_table(["Name", "Billable"], rows)
452
+ end
453
+
454
+ # --- status ---
455
+
456
+ desc "status", "Show hours summary for today, this week, and this month"
457
+ def status
458
+ Auth.valid_access_token
459
+ today = Date.today
460
+ week_start = today - ((today.wday - 1) % 7)
461
+ month_start = Date.new(today.year, today.month, 1)
462
+
463
+ entries = Spinner.spin("Fetching time entries") do
464
+ Api.fetch_time_entries(started_from: month_start.to_s, started_to: today.to_s)
465
+ end
466
+ maps = Spinner.spin("Resolving names") { Api.build_name_maps }
467
+
468
+ today_entries = entries.select { |e| e["started_at"] == today.to_s }
469
+ week_entries = entries.select { |e| d = e["started_at"]; d && d >= week_start.to_s && d <= today.to_s }
470
+ month_entries = entries
471
+
472
+ if options[:format] == "json"
473
+ puts JSON.pretty_generate({
474
+ "today" => build_status_data(today.to_s, today.to_s, today_entries, maps),
475
+ "this_week" => build_status_data(week_start.to_s, today.to_s, week_entries, maps),
476
+ "this_month" => build_status_data(month_start.to_s, today.to_s, month_entries, maps)
477
+ })
478
+ return
479
+ end
480
+
481
+ print_status_section("Today (#{today})", today_entries, maps)
482
+ print_status_section("This Week (#{week_start} to #{today})", week_entries, maps)
483
+ print_status_section("This Month (#{month_start} to #{today})", month_entries, maps)
484
+ end
485
+
486
+ # --- delete ---
487
+
488
+ desc "delete", "Delete a time entry"
489
+ method_option :id, type: :numeric, desc: "Time entry ID (skip interactive picker)"
490
+ method_option :yes, type: :boolean, default: false, desc: "Skip confirmation"
491
+ def delete
492
+ Auth.valid_access_token
493
+
494
+ if options[:id]
495
+ entry_id = options[:id]
496
+ else
497
+ abort("Missing required flag: --id") unless interactive?
498
+ entry_id = pick_entry_interactive("delete")
499
+ end
500
+
501
+ unless options[:yes]
502
+ print "Delete entry #{entry_id}? (y/N): "
503
+ answer = $stdin.gets&.strip&.downcase
504
+ abort("Cancelled.") unless answer == "y"
505
+ end
506
+
507
+ Spinner.spin("Deleting time entry") { Api.delete_time_entry(entry_id) }
508
+
509
+ if options[:format] == "json"
510
+ puts JSON.pretty_generate({ "id" => entry_id, "deleted" => true })
511
+ else
512
+ puts "Time entry #{entry_id} deleted."
513
+ end
514
+ end
515
+
516
+ # --- edit ---
517
+
518
+ desc "edit", "Edit a time entry"
519
+ method_option :id, type: :numeric, desc: "Time entry ID (skip interactive picker)"
520
+ method_option :duration, type: :numeric, desc: "New duration in hours"
521
+ method_option :note, type: :string, desc: "New note"
522
+ method_option :date, type: :string, desc: "New date (YYYY-MM-DD)"
523
+ method_option :client, type: :string, desc: "New client name"
524
+ method_option :project, type: :string, desc: "New project name"
525
+ method_option :service, type: :string, desc: "New service name"
526
+ method_option :internal, type: :boolean, default: false, desc: "Move entry to an internal project with no client"
527
+ method_option :yes, type: :boolean, default: false, desc: "Skip confirmation"
528
+ def edit
529
+ Auth.valid_access_token
530
+
531
+ if options[:id]
532
+ entry_id = options[:id]
533
+ else
534
+ abort("Missing required flag: --id") unless interactive?
535
+ entry_id = pick_entry_interactive("edit")
536
+ end
537
+
538
+ entry = Spinner.spin("Fetching time entry") { Api.fetch_time_entry(entry_id) }
539
+ abort("Time entry not found.") unless entry
540
+
541
+ maps = Spinner.spin("Resolving names") { Api.build_name_maps }
542
+ has_edit_flags = options[:duration] || options[:note] || options[:date] || options[:client] || options[:project] || options[:service] || options[:internal]
543
+ scripted = has_edit_flags || !interactive?
544
+
545
+ fields = build_edit_fields(entry, maps, scripted)
546
+
547
+ current_client = maps[:clients][entry["client_id"].to_s] || entry["client_id"].to_s
548
+ current_project = maps[:projects][entry["project_id"].to_s] || "-"
549
+ current_hours = (entry["duration"].to_i / 3600.0).round(2)
550
+ new_hours = fields["duration"] ? (fields["duration"].to_i / 3600.0).round(2) : current_hours
551
+ summary_client = if fields.key?("client_id")
552
+ fields["client_id"] ? maps[:clients][fields["client_id"].to_s] : "Internal"
553
+ elsif fields["project_id"] != entry["project_id"]
554
+ "Internal"
555
+ else
556
+ current_client
557
+ end
558
+
559
+ unless options[:format] == "json"
560
+ puts "\n--- Edit Summary ---"
561
+ puts " Date: #{fields["started_at"] || entry["started_at"]}"
562
+ puts " Client: #{summary_client}"
563
+ puts " Project: #{fields["project_id"] ? maps[:projects][fields["project_id"].to_s] : current_project}"
564
+ puts " Duration: #{new_hours}h"
565
+ puts " Note: #{fields["note"] || entry["note"]}"
566
+ puts "--------------------\n\n"
567
+ end
568
+
569
+ unless options[:yes]
570
+ print "Save changes? (Y/n): "
571
+ answer = $stdin.gets&.strip&.downcase
572
+ abort("Cancelled.") if answer == "n"
573
+ end
574
+
575
+ result = Spinner.spin("Updating time entry") { Api.update_time_entry(entry_id, fields) }
576
+
577
+ if options[:format] == "json"
578
+ puts JSON.pretty_generate(result)
579
+ else
580
+ puts "Time entry #{entry_id} updated."
581
+ end
582
+ end
583
+
584
+ # --- cache ---
585
+
586
+ desc "cache SUBCOMMAND", "Manage cached data (refresh, clear, status)"
587
+ def cache(subcommand = "status")
588
+ case subcommand
589
+ when "refresh"
590
+ Auth.valid_access_token
591
+ Spinner.spin("Refreshing cache") do
592
+ Api.fetch_clients(force: true)
593
+ Api.fetch_projects(force: true)
594
+ Api.fetch_services(force: true)
595
+ Api.build_name_maps
596
+ end
597
+ puts "Cache refreshed."
598
+ when "clear"
599
+ if File.exist?(Auth.cache_path)
600
+ File.delete(Auth.cache_path)
601
+ puts "Cache cleared."
602
+ else
603
+ puts "No cache file found."
604
+ end
605
+ when "status"
606
+ cache = Auth.load_cache
607
+ if options[:format] == "json"
608
+ if cache["updated_at"]
609
+ age = Time.now.to_i - cache["updated_at"]
610
+ puts JSON.pretty_generate({
611
+ "updated_at" => cache["updated_at"],
612
+ "age_seconds" => age,
613
+ "fresh" => age < 600,
614
+ "clients" => (cache["clients_data"] || []).length,
615
+ "projects" => (cache["projects_data"] || []).length,
616
+ "services" => (cache["services"] || cache["services_data"] || {}).length
617
+ })
618
+ else
619
+ puts JSON.pretty_generate({ "fresh" => false, "clients" => 0, "projects" => 0, "services" => 0 })
620
+ end
621
+ return
622
+ end
623
+
624
+ if cache["updated_at"]
625
+ age = Time.now.to_i - cache["updated_at"]
626
+ updated = Time.at(cache["updated_at"]).strftime("%Y-%m-%d %H:%M:%S")
627
+ fresh = age < 600
628
+ puts "Cache updated: #{updated}"
629
+ puts "Age: #{age}s (#{fresh ? "fresh" : "stale"})"
630
+ puts "Clients: #{(cache["clients_data"] || []).length}"
631
+ puts "Projects: #{(cache["projects_data"] || []).length}"
632
+ puts "Services: #{(cache["services"] || cache["services_data"] || {}).length}"
633
+ else
634
+ puts "No cache data."
635
+ end
636
+ else
637
+ abort("Unknown cache subcommand: #{subcommand}. Use: refresh, clear, status")
638
+ end
639
+ end
640
+
641
+ # --- help ---
642
+
643
+ desc "help [COMMAND]", "Describe available commands or one specific command"
644
+ def help(command = nil)
645
+ if options[:format] == "json"
646
+ puts JSON.pretty_generate(help_json)
647
+ return
648
+ end
649
+ super
650
+ end
651
+
652
+ private
653
+
654
+ def interactive?
655
+ return false if options[:no_interactive]
656
+ return true if options[:interactive]
657
+ $stdin.tty?
658
+ end
659
+
660
+ def resolve_log_context(defaults)
661
+ validate_internal_log_options!
662
+
663
+ if options[:project] && (!options[:client] || options[:internal])
664
+ project = resolve_project_by_name(options[:project], force: true)
665
+ ensure_project_matches_internal_option!(project)
666
+ client = project_internal?(project) ? nil : resolve_client_for_project(project)
667
+ return { client: client, project: project }
668
+ end
669
+
670
+ client = select_client(defaults)
671
+ project = select_project(client["id"], defaults)
672
+ { client: client, project: project }
673
+ end
674
+
675
+ def validate_internal_log_options!
676
+ abort("Cannot combine --client with --internal.") if options[:client] && options[:internal]
677
+ abort("--internal requires --project.") if options[:internal] && !options[:project]
678
+ end
679
+
680
+ def validate_internal_edit_options!
681
+ abort("Cannot combine --client with --internal.") if options[:client] && options[:internal]
682
+ abort("--internal requires --project.") if options[:internal] && !options[:project]
683
+ end
684
+
685
+ def resolve_project_by_name(name, force: false)
686
+ projects = Spinner.spin("Fetching projects") { Api.fetch_projects(force: force) }
687
+ match = projects.find { |project| project["title"].downcase == name.downcase }
688
+ abort("Project not found: #{name}") unless match
689
+ match
690
+ end
691
+
692
+ def project_internal?(project)
693
+ project["internal"] || project["client_id"].nil?
694
+ end
695
+
696
+ def display_client_name(client_id, maps)
697
+ return "Internal" if client_id.nil?
698
+ maps[:clients][client_id.to_s] || client_id.to_s
699
+ end
700
+
701
+ def display_project_client_name(project, maps)
702
+ return "Internal" if project_internal?(project)
703
+ maps[:clients][project["client_id"].to_s] || "-"
704
+ end
705
+
706
+ def ensure_project_matches_internal_option!(project)
707
+ abort("Project #{project["title"]} is not internal.") if options[:internal] && !project_internal?(project)
708
+ end
709
+
710
+ def resolve_client_for_project(project)
711
+ clients = Spinner.spin("Fetching clients") { Api.fetch_clients }
712
+ match = clients.find { |client| client["id"].to_i == project["client_id"].to_i }
713
+ abort("Client not found for project: #{project["title"]}") unless match
714
+ match
715
+ end
716
+
717
+ def select_client(defaults)
718
+ clients = Spinner.spin("Fetching clients") { Api.fetch_clients }
719
+
720
+ if options[:client]
721
+ match = clients.find { |c| display_name(c).downcase == options[:client].downcase }
722
+ abort("Client not found: #{options[:client]}") unless match
723
+ return match
724
+ end
725
+
726
+ abort("No clients found.") if clients.empty?
727
+
728
+ unless interactive?
729
+ # Non-interactive: auto-select if single, abort with list if multiple
730
+ default_client = clients.find { |c| c["id"].to_i == defaults["client_id"].to_i }
731
+ return default_client if default_client
732
+ return clients.first if clients.length == 1
733
+ names = clients.map { |c| display_name(c) }.join(", ")
734
+ abort("Multiple clients found. Use --client to specify: #{names}")
735
+ end
736
+
737
+ puts "\nClients:\n\n"
738
+ clients.each_with_index do |c, i|
739
+ name = display_name(c)
740
+ default_marker = c["id"].to_i == defaults["client_id"].to_i ? " [default]" : ""
741
+ puts " #{i + 1}. #{name}#{default_marker}"
742
+ end
743
+
744
+ default_idx = clients.index { |c| c["id"].to_i == defaults["client_id"].to_i }
745
+ prompt = default_idx ? "\nSelect client (1-#{clients.length}) [#{default_idx + 1}]: " : "\nSelect client (1-#{clients.length}): "
746
+ print prompt
747
+ input = $stdin.gets&.strip
748
+
749
+ idx = if input.nil? || input.empty?
750
+ default_idx || 0
751
+ else
752
+ input.to_i - 1
753
+ end
754
+
755
+ abort("Invalid selection.") if idx < 0 || idx >= clients.length
756
+ clients[idx]
757
+ end
758
+
759
+ def select_project(client_id, defaults)
760
+ projects = Spinner.spin("Fetching projects") { Api.fetch_projects_for_client(client_id) }
761
+
762
+ if options[:project]
763
+ match = projects.find { |p| p["title"].downcase == options[:project].downcase }
764
+ abort("Project not found: #{options[:project]}") unless match
765
+ return match
766
+ end
767
+
768
+ return nil if projects.empty?
769
+
770
+ unless interactive?
771
+ # Non-interactive: auto-select if single, return nil if multiple (optional)
772
+ default_project = projects.find { |p| p["id"].to_i == defaults["project_id"].to_i }
773
+ return default_project if default_project
774
+ return projects.first if projects.length == 1
775
+ return nil
776
+ end
777
+
778
+ puts "\nProjects:\n\n"
779
+ projects.each_with_index do |p, i|
780
+ default_marker = p["id"].to_i == defaults["project_id"].to_i ? " [default]" : ""
781
+ puts " #{i + 1}. #{p["title"]}#{default_marker}"
782
+ end
783
+
784
+ default_idx = projects.index { |p| p["id"].to_i == defaults["project_id"].to_i }
785
+ prompt = default_idx ? "\nSelect project (1-#{projects.length}, Enter to skip) [#{default_idx + 1}]: " : "\nSelect project (1-#{projects.length}, Enter to skip): "
786
+ print prompt
787
+ input = $stdin.gets&.strip
788
+
789
+ if input.nil? || input.empty?
790
+ return default_idx ? projects[default_idx] : nil
791
+ end
792
+
793
+ idx = input.to_i - 1
794
+ return nil if idx < 0 || idx >= projects.length
795
+ projects[idx]
796
+ end
797
+
798
+ def select_service(defaults, project = nil)
799
+ # Use project-scoped services if available, fall back to global
800
+ services = if project && project["services"] && !project["services"].empty?
801
+ project["services"]
802
+ else
803
+ Spinner.spin("Fetching services") { Api.fetch_services }
804
+ end
805
+
806
+ if options[:service]
807
+ match = services.find { |s| s["name"].downcase == options[:service].downcase }
808
+ abort("Service not found: #{options[:service]}") unless match
809
+ return match
810
+ end
811
+
812
+ unless interactive?
813
+ # Non-interactive: auto-select if single, use default if set, otherwise skip
814
+ default_service = services.find { |s| s["id"].to_i == defaults["service_id"].to_i }
815
+ return default_service if default_service
816
+ return services.first if services.length == 1
817
+ return nil
818
+ end
819
+
820
+ return nil if services.empty?
821
+
822
+ puts "\nServices:\n\n"
823
+ services.each_with_index do |s, i|
824
+ default_marker = s["id"].to_i == defaults["service_id"].to_i ? " [default]" : ""
825
+ puts " #{i + 1}. #{s["name"]}#{default_marker}"
826
+ end
827
+
828
+ default_idx = services.index { |s| s["id"].to_i == defaults["service_id"].to_i }
829
+ prompt = default_idx ? "\nSelect service (1-#{services.length}, Enter to skip) [#{default_idx + 1}]: " : "\nSelect service (1-#{services.length}, Enter to skip): "
830
+ print prompt
831
+ input = $stdin.gets&.strip
832
+
833
+ if input.nil? || input.empty?
834
+ return default_idx ? services[default_idx] : nil
835
+ end
836
+
837
+ idx = input.to_i - 1
838
+ return nil if idx < 0 || idx >= services.length
839
+ services[idx]
840
+ end
841
+
842
+ def pick_date
843
+ return options[:date] if options[:date]
844
+
845
+ today = Date.today.to_s
846
+ return today unless interactive?
847
+
848
+ print "\nDate [#{today}]: "
849
+ input = $stdin.gets&.strip
850
+ (input.nil? || input.empty?) ? today : input
851
+ end
852
+
853
+ def pick_duration
854
+ return options[:duration] if options[:duration]
855
+
856
+ abort("Missing required flag: --duration") unless interactive?
857
+
858
+ print "\nDuration (hours): "
859
+ input = $stdin.gets&.strip
860
+ abort("Duration is required.") if input.nil? || input.empty?
861
+ input.to_f
862
+ end
863
+
864
+ def pick_note
865
+ return options[:note] if options[:note]
866
+
867
+ abort("Missing required flag: --note") unless interactive?
868
+
869
+ print "\nNote: "
870
+ input = $stdin.gets&.strip
871
+ abort("Note is required.") if input.nil? || input.empty?
872
+ input
873
+ end
874
+
875
+ def print_table(headers, rows, wrap_col: nil)
876
+ widths = headers.each_with_index.map do |h, i|
877
+ [h.length, *rows.map { |r| r[i].to_s.length }].max
878
+ end
879
+
880
+ # Word-wrap a specific column if it would exceed terminal width
881
+ if wrap_col
882
+ term_width = IO.console&.winsize&.last || ENV["COLUMNS"]&.to_i || 120
883
+ fixed_width = widths.each_with_index.sum { |w, i| i == wrap_col ? 0 : w } + (widths.length - 1) * 2
884
+ max_wrap = term_width - fixed_width
885
+ max_wrap = [max_wrap, 20].max
886
+ widths[wrap_col] = [widths[wrap_col], max_wrap].min
887
+ end
888
+
889
+ fmt = widths.map { |w| "%-#{w}s" }.join(" ")
890
+ puts fmt % headers
891
+ puts widths.map { |w| "-" * w }.join(" ")
892
+
893
+ rows.each do |r|
894
+ if wrap_col && r[wrap_col].to_s.length > widths[wrap_col]
895
+ lines = word_wrap(r[wrap_col].to_s, widths[wrap_col])
896
+ padded = widths.each_with_index.map { |w, i| i == wrap_col ? "" : " " * w }
897
+ pad_fmt = padded.each_with_index.map { |p, i| i == wrap_col ? "%s" : "%-#{widths[i]}s" }.join(" ")
898
+ lines.each_with_index do |line, li|
899
+ if li == 0
900
+ row = r.dup
901
+ row[wrap_col] = line
902
+ puts fmt % row
903
+ else
904
+ blank = padded.dup
905
+ blank[wrap_col] = line
906
+ puts pad_fmt % blank
907
+ end
908
+ end
909
+ else
910
+ puts fmt % r
911
+ end
912
+ end
913
+ end
914
+
915
+ def word_wrap(text, width)
916
+ lines = []
917
+ remaining = text
918
+ while remaining.length > width
919
+ break_at = remaining.rindex(" ", width) || width
920
+ lines << remaining[0...break_at]
921
+ remaining = remaining[break_at..].lstrip
922
+ end
923
+ lines << remaining unless remaining.empty?
924
+ lines
925
+ end
926
+
927
+ def print_status_section(title, entries, maps)
928
+ puts "\n#{title}"
929
+ if entries.empty?
930
+ puts " No entries."
931
+ return
932
+ end
933
+
934
+ grouped = {}
935
+ entries.each do |e|
936
+ client = display_client_name(e["client_id"], maps)
937
+ project = maps[:projects][e["project_id"].to_s] || "-"
938
+ key = "#{client} / #{project}"
939
+ grouped[key] ||= 0.0
940
+ grouped[key] += e["duration"].to_i / 3600.0
941
+ end
942
+
943
+ grouped.each do |key, hours|
944
+ puts " #{key}: #{hours.round(2)}h"
945
+ end
946
+
947
+ total = entries.sum { |e| e["duration"].to_i } / 3600.0
948
+ puts " Total: #{total.round(2)}h"
949
+ end
950
+
951
+ def build_status_data(from, to, entries, maps)
952
+ entry_data = entries.map do |e|
953
+ {
954
+ "id" => e["id"],
955
+ "client" => display_client_name(e["client_id"], maps),
956
+ "project" => maps[:projects][e["project_id"].to_s] || "-",
957
+ "duration" => e["duration"],
958
+ "hours" => (e["duration"].to_i / 3600.0).round(2),
959
+ "note" => e["note"],
960
+ "started_at" => e["started_at"]
961
+ }
962
+ end
963
+ total = entries.sum { |e| e["duration"].to_i } / 3600.0
964
+ { "from" => from, "to" => to, "entries" => entry_data, "total_hours" => total.round(2) }
965
+ end
966
+
967
+ def pick_entry_interactive(action)
968
+ today = Date.today.to_s
969
+ entries = Spinner.spin("Fetching today's entries") do
970
+ Api.fetch_time_entries(started_from: today, started_to: today)
971
+ end
972
+ abort("No entries found for today.") if entries.empty?
973
+
974
+ maps = Spinner.spin("Resolving names") { Api.build_name_maps }
975
+
976
+ puts "\nToday's entries:\n\n"
977
+ entries.each_with_index do |e, i|
978
+ client = display_client_name(e["client_id"], maps)
979
+ hours = (e["duration"].to_i / 3600.0).round(2)
980
+ note = (e["note"] || "").slice(0, 40)
981
+ puts " #{i + 1}. [#{e["id"]}] #{client} — #{hours}h — #{note}"
982
+ end
983
+
984
+ print "\nSelect entry to #{action} (1-#{entries.length}): "
985
+ input = $stdin.gets&.strip
986
+ abort("Cancelled.") if input.nil? || input.empty?
987
+
988
+ idx = input.to_i - 1
989
+ abort("Invalid selection.") if idx < 0 || idx >= entries.length
990
+ entries[idx]["id"]
991
+ end
992
+
993
+ def build_edit_fields(entry, maps, scripted)
994
+ # FreshBooks API replaces the entry — always include all current fields
995
+ fields = {
996
+ "started_at" => entry["started_at"],
997
+ "is_logged" => entry["is_logged"] || true,
998
+ "duration" => entry["duration"],
999
+ "note" => entry["note"],
1000
+ "client_id" => entry["client_id"],
1001
+ "project_id" => entry["project_id"],
1002
+ "service_id" => entry["service_id"]
1003
+ }
1004
+
1005
+ if scripted
1006
+ validate_internal_edit_options!
1007
+ fields["duration"] = (options[:duration] * 3600).to_i if options[:duration]
1008
+ fields["note"] = options[:note] if options[:note]
1009
+ fields["started_at"] = normalize_datetime(options[:date]) if options[:date]
1010
+
1011
+ if options[:project]
1012
+ project = resolve_project_by_name(options[:project], force: true)
1013
+ ensure_project_matches_internal_option!(project)
1014
+ fields["project_id"] = project["id"]
1015
+ if project_internal?(project)
1016
+ fields.delete("client_id")
1017
+ else
1018
+ fields["client_id"] = project["client_id"].to_i
1019
+ end
1020
+ elsif options[:client]
1021
+ client_id = maps[:clients].find { |_id, name| name.downcase == options[:client].downcase }&.first
1022
+ abort("Client not found: #{options[:client]}") unless client_id
1023
+ fields["client_id"] = client_id.to_i
1024
+ end
1025
+
1026
+ if options[:service]
1027
+ service_id = maps[:services].find { |_id, name| name.downcase == options[:service].downcase }&.first
1028
+ abort("Service not found: #{options[:service]}") unless service_id
1029
+ fields["service_id"] = service_id.to_i
1030
+ end
1031
+ else
1032
+ current_hours = (entry["duration"].to_i / 3600.0).round(2)
1033
+ print "\nDuration (hours) [#{current_hours}]: "
1034
+ input = $stdin.gets&.strip
1035
+ fields["duration"] = (input.to_f * 3600).to_i unless input.nil? || input.empty?
1036
+
1037
+ current_note = entry["note"] || ""
1038
+ print "Note [#{current_note}]: "
1039
+ input = $stdin.gets&.strip
1040
+ fields["note"] = input unless input.nil? || input.empty?
1041
+
1042
+ current_date = entry["started_at"] || ""
1043
+ print "Date [#{current_date}]: "
1044
+ input = $stdin.gets&.strip
1045
+ fields["started_at"] = input unless input.nil? || input.empty?
1046
+ end
1047
+
1048
+ fields
1049
+ end
1050
+
1051
+ def normalize_datetime(date_str)
1052
+ return date_str if date_str.include?("T")
1053
+ "#{date_str}T00:00:00Z"
1054
+ end
1055
+
1056
+ def display_name(client)
1057
+ name = client["organization"]
1058
+ (name.nil? || name.empty?) ? "#{client["fname"]} #{client["lname"]}" : name
1059
+ end
1060
+
1061
+ def help_json
1062
+ {
1063
+ name: "fb",
1064
+ description: "FreshBooks time tracking CLI",
1065
+ required_scopes: Auth::REQUIRED_SCOPES,
1066
+ global_flags: {
1067
+ "--no-interactive" => "Disable interactive prompts (auto-detected when not a TTY)",
1068
+ "--format json" => "Output format: json (available on all commands)",
1069
+ "--dry-run" => "Simulate command without making network calls (writes skipped)"
1070
+ },
1071
+ commands: {
1072
+ auth: {
1073
+ description: "Authenticate with FreshBooks via OAuth2",
1074
+ usage: "fb auth [SUBCOMMAND]",
1075
+ interactive: "Interactive when no subcommand; subcommands are non-interactive",
1076
+ subcommands: {
1077
+ "setup" => "Save OAuth credentials from env vars: FRESHBOOKS_CLIENT_ID, FRESHBOOKS_CLIENT_SECRET (or ~/.fb/.env)",
1078
+ "url" => "Print the OAuth authorization URL",
1079
+ "callback" => "Exchange OAuth code: fb auth callback REDIRECT_URL",
1080
+ "status" => "Show current auth state (config, tokens, business)"
1081
+ },
1082
+ flags: {}
1083
+ },
1084
+ business: {
1085
+ description: "List or select a business",
1086
+ usage: "fb business [--select ID]",
1087
+ interactive: "Interactive with --select (no value); non-interactive with --select ID",
1088
+ flags: {
1089
+ "--select ID" => "Set active business by ID",
1090
+ "--select" => "Interactive business picker (no value)"
1091
+ }
1092
+ },
1093
+ log: {
1094
+ description: "Log a time entry",
1095
+ usage: "fb log",
1096
+ interactive: "Prompts for missing fields when interactive; requires --duration and --note when non-interactive",
1097
+ flags: {
1098
+ "--client" => "Client name (required only for client-backed resolution when multiple clients exist)",
1099
+ "--project" => "Project name (can be internal; internal projects may be used without --client)",
1100
+ "--service" => "Service name (project-scoped; optional if single/default)",
1101
+ "--duration" => "Duration in hours, e.g. 2.5 (required non-interactive)",
1102
+ "--note" => "Work description (required non-interactive)",
1103
+ "--date" => "Date YYYY-MM-DD (defaults to today)",
1104
+ "--internal" => "Force internal-project resolution; requires --project and conflicts with --client",
1105
+ "--yes" => "Skip confirmation prompt"
1106
+ }
1107
+ },
1108
+ entries: {
1109
+ description: "List time entries (defaults to current month)",
1110
+ usage: "fb entries",
1111
+ flags: {
1112
+ "--from" => "Start date (YYYY-MM-DD, open-ended if omitted)",
1113
+ "--to" => "End date (YYYY-MM-DD, open-ended if omitted)",
1114
+ "--month" => "Month (1-12, defaults to current)",
1115
+ "--year" => "Year (defaults to current)"
1116
+ }
1117
+ },
1118
+ clients: {
1119
+ description: "List all clients",
1120
+ usage: "fb clients"
1121
+ },
1122
+ projects: {
1123
+ description: "List all projects",
1124
+ usage: "fb projects",
1125
+ flags: {
1126
+ "--client" => "Filter by client name"
1127
+ }
1128
+ },
1129
+ services: {
1130
+ description: "List all services",
1131
+ usage: "fb services"
1132
+ },
1133
+ status: {
1134
+ description: "Show hours summary for today, this week, and this month",
1135
+ usage: "fb status"
1136
+ },
1137
+ delete: {
1138
+ description: "Delete a time entry",
1139
+ usage: "fb delete --id ID --yes",
1140
+ interactive: "Interactive picker when no --id; requires --id when non-interactive",
1141
+ flags: {
1142
+ "--id" => "Time entry ID (required non-interactive)",
1143
+ "--yes" => "Skip confirmation prompt"
1144
+ }
1145
+ },
1146
+ edit: {
1147
+ description: "Edit a time entry",
1148
+ usage: "fb edit --id ID [--duration H] [--note TEXT] --yes",
1149
+ interactive: "Interactive picker and field editor when no --id; requires --id when non-interactive",
1150
+ flags: {
1151
+ "--id" => "Time entry ID (required non-interactive)",
1152
+ "--duration" => "New duration in hours",
1153
+ "--note" => "New note",
1154
+ "--date" => "New date (YYYY-MM-DD)",
1155
+ "--client" => "New client name",
1156
+ "--project" => "New project name (internal projects omit client_id)",
1157
+ "--service" => "New service name",
1158
+ "--internal" => "Force internal-project resolution; requires --project and conflicts with --client",
1159
+ "--yes" => "Skip confirmation prompt"
1160
+ }
1161
+ },
1162
+ cache: {
1163
+ description: "Manage cached data",
1164
+ usage: "fb cache [refresh|clear|status]",
1165
+ subcommands: {
1166
+ "refresh" => "Force-refresh all cached data",
1167
+ "clear" => "Delete cache file",
1168
+ "status" => "Show cache age and staleness"
1169
+ }
1170
+ },
1171
+ help: {
1172
+ description: "Show help information",
1173
+ usage: "fb help [COMMAND]"
1174
+ }
1175
+ }
1176
+ }
1177
+ end
1178
+ end # closes class Commands
1179
+ end # closes module CLI
1180
+ end # closes module FreshBooks