freshbooks-cli 0.4.0 → 0.5.1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 84f4c3c33549e8e6f23749a6597fe394d6d94b248c54453810c4bd198b3e4294
4
- data.tar.gz: 76d88cb16bf1d4b9298869c4b0a9db068157cbd6e068c851e27a834516b5baa8
3
+ metadata.gz: 72c774411761de2ede345e21a6961b2dace9debd2adf089b948c6cdd2447ac9b
4
+ data.tar.gz: a289b2ae8edb32a9759fc35b96165dc261254594a8711291e412a3ad1c69ece5
5
5
  SHA512:
6
- metadata.gz: 0cba9865b5c7b5e1e2e2b11590be7044803ca4afbb0a8eeba1d3136a84fc5ebaadfd542bf531d3d37073274936f30794dad006348a09bc4fc424a79ef993fc73
7
- data.tar.gz: 8d11babdd9b8f3406a664d13f89cca6c5fd915b8154044bfe9dddb3fea2b620ac99d8c0b774a8ffa81c5d32c6e9dfb4521ffaa728dc1f3b68d122e645c21de3d
6
+ metadata.gz: 88c4848ab50045afa539f92fee68585b0bcdc61719cb99723b3758eba772ace18f810b1c81d8f024173604c4161ab0b4ada6b247a32d69ba3005c572ed341b79
7
+ data.tar.gz: 21304aa42d250e86ccf868342158a7d8f1135a7344bdc1407a4395de1bd7cccdf72f55dba9d4a3bb0cab22f73a7223c524788b96b537fd264ad9402c23d7afb4
@@ -41,8 +41,6 @@ module FreshBooks
41
41
  # --- Paginated fetch ---
42
42
 
43
43
  def fetch_all_pages(url, result_key, params: {})
44
- return [] if Thread.current[:fb_dry_run]
45
-
46
44
  page = 1
47
45
  all_items = []
48
46
 
@@ -132,8 +130,6 @@ module FreshBooks
132
130
  # --- Services ---
133
131
 
134
132
  def fetch_services(force: false)
135
- return (Auth.load_cache["services_data"] || []) if Thread.current[:fb_dry_run]
136
-
137
133
  unless force
138
134
  cached = cached_data("services_data")
139
135
  return cached if cached
@@ -9,6 +9,8 @@ require "dotenv"
9
9
  module FreshBooks
10
10
  module CLI
11
11
  class Auth
12
+ class TokenRefreshError < StandardError; end
13
+
12
14
  TOKEN_URL = "https://api.freshbooks.com/auth/oauth/token"
13
15
  AUTH_URL = "https://auth.freshbooks.com/oauth/authorize"
14
16
  ME_URL = "https://api.freshbooks.com/auth/api/v1/users/me"
@@ -60,6 +62,10 @@ module FreshBooks
60
62
  File.join(data_dir, "tokens.json")
61
63
  end
62
64
 
65
+ def tokens_lock_path
66
+ File.join(data_dir, "tokens.json.lock")
67
+ end
68
+
63
69
  def defaults_path
64
70
  File.join(data_dir, "defaults.json")
65
71
  end
@@ -200,14 +206,28 @@ module FreshBooks
200
206
  def auth_status
201
207
  config = load_config
202
208
  tokens = load_tokens
209
+ refresh_error = nil
210
+
211
+ if config && tokens && token_expired?(tokens)
212
+ begin
213
+ tokens = refresh_token_with_lock(config, tokens, abort_on_failure: false)
214
+ rescue TokenRefreshError => e
215
+ refresh_error = e.message
216
+ end
217
+ end
218
+
219
+ tokens_expired = tokens ? token_expired?(tokens) : nil
220
+ requires_reauth = config.nil? || tokens.nil? || tokens_expired == true
203
221
  {
204
222
  "config_exists" => !config.nil?,
205
223
  "config_path" => config_path,
206
224
  "tokens_exist" => !tokens.nil?,
207
- "tokens_expired" => tokens ? token_expired?(tokens) : nil,
225
+ "tokens_expired" => tokens_expired,
226
+ "requires_reauth" => requires_reauth,
227
+ "refresh_error" => refresh_error,
208
228
  "business_id" => config&.dig("business_id"),
209
229
  "account_id" => config&.dig("account_id")
210
- }
230
+ }.compact
211
231
  end
212
232
 
213
233
  def fetch_businesses(access_token)
@@ -258,7 +278,15 @@ module FreshBooks
258
278
 
259
279
  def save_tokens(tokens)
260
280
  ensure_data_dir
261
- File.write(tokens_path, JSON.pretty_generate(tokens) + "\n")
281
+ tmp_path = "#{tokens_path}.#{$$}.tmp"
282
+ File.open(tmp_path, File::WRONLY | File::CREAT | File::TRUNC, 0o600) do |file|
283
+ file.write(JSON.pretty_generate(tokens) + "\n")
284
+ file.flush
285
+ file.fsync
286
+ end
287
+ File.rename(tmp_path, tokens_path)
288
+ ensure
289
+ File.delete(tmp_path) if tmp_path && File.exist?(tmp_path)
262
290
  end
263
291
 
264
292
  def token_expired?(tokens)
@@ -268,22 +296,30 @@ module FreshBooks
268
296
  Time.now.to_i >= (created + expires_in - 60)
269
297
  end
270
298
 
271
- def refresh_token!(config, tokens)
272
- response = HTTParty.post(TOKEN_URL, {
273
- headers: { "Content-Type" => "application/json" },
274
- body: {
275
- grant_type: "refresh_token",
276
- client_id: config["client_id"],
277
- client_secret: config["client_secret"],
278
- redirect_uri: REDIRECT_URI,
279
- refresh_token: tokens["refresh_token"]
280
- }.to_json
281
- })
299
+ def refresh_token!(config, tokens, abort_on_failure: true)
300
+ begin
301
+ response = HTTParty.post(TOKEN_URL, {
302
+ headers: { "Content-Type" => "application/json" },
303
+ body: {
304
+ grant_type: "refresh_token",
305
+ client_id: config["client_id"],
306
+ client_secret: config["client_secret"],
307
+ redirect_uri: REDIRECT_URI,
308
+ refresh_token: tokens["refresh_token"]
309
+ }.to_json
310
+ })
311
+ rescue StandardError => e
312
+ message = "Token refresh failed: #{e.class}: #{e.message}\nPlease re-run: fb auth"
313
+ raise if abort_on_failure
314
+ raise TokenRefreshError, message
315
+ end
282
316
 
283
317
  unless response.success?
284
318
  body = response.parsed_response
285
319
  msg = body.is_a?(Hash) ? (body["error_description"] || body["error"] || response.body) : response.body
286
- abort("Token refresh failed: #{msg}\nPlease re-run: fb auth")
320
+ message = "Token refresh failed: #{msg}\nPlease re-run: fb auth"
321
+ abort(message) if abort_on_failure
322
+ raise TokenRefreshError, message
287
323
  end
288
324
 
289
325
  data = response.parsed_response
@@ -297,6 +333,20 @@ module FreshBooks
297
333
  new_tokens
298
334
  end
299
335
 
336
+ def refresh_token_with_lock(config, tokens, abort_on_failure: true)
337
+ ensure_data_dir
338
+ File.open(tokens_lock_path, File::RDWR | File::CREAT, 0o600) do |lock_file|
339
+ lock_file.flock(File::LOCK_EX)
340
+
341
+ latest_tokens = load_tokens || tokens
342
+ return latest_tokens if latest_tokens && !token_expired?(latest_tokens)
343
+
344
+ refresh_token!(config, latest_tokens || tokens, abort_on_failure: abort_on_failure)
345
+ ensure
346
+ lock_file.flock(File::LOCK_UN)
347
+ end
348
+ end
349
+
300
350
  def valid_access_token
301
351
  if Thread.current[:fb_dry_run]
302
352
  tokens = load_tokens
@@ -315,7 +365,7 @@ module FreshBooks
315
365
 
316
366
  if token_expired?(tokens)
317
367
  puts "Token expired, refreshing..."
318
- tokens = refresh_token!(config, tokens)
368
+ tokens = refresh_token_with_lock(config, tokens)
319
369
  end
320
370
 
321
371
  tokens["access_token"]
@@ -13,10 +13,48 @@ module FreshBooks
13
13
  true
14
14
  end
15
15
 
16
+ def self.dispatch(command, given_args, given_opts, config)
17
+ requested_command = command || given_args.first
18
+
19
+ super
20
+ rescue Thor::UnknownArgumentError => e
21
+ if requested_command.to_s == "log"
22
+ unknown_option = e.unknown.first
23
+ raise Thor::InvocationError, log_parse_error_message(unknown_option, e.message)
24
+ end
25
+
26
+ raise
27
+ end
28
+
29
+ def self.handle_argument_error(command, error, args, arity)
30
+ unknown_option = args.find { |arg| arg.to_s.start_with?("--") }
31
+ if command.name == "log" && unknown_option
32
+ raise Thor::InvocationError, log_parse_error_message(unknown_option, error.message)
33
+ end
34
+
35
+ super
36
+ end
37
+
38
+ def self.log_parse_error_message(option, fallback)
39
+ suggestion = { "--hours" => "--duration", "--notes" => "--note" }[option]
40
+ lines = ["Unknown option: #{option || fallback}"]
41
+ lines << "Did you mean: #{suggestion}" if suggestion
42
+ lines.concat([
43
+ "",
44
+ "fb log requires:",
45
+ " --duration HOURS",
46
+ " --note TEXT",
47
+ "",
48
+ "Example:",
49
+ ' fb log --project "Sample Project" --service "Development" --duration 0.5 --note "Reviewed pull requests" --yes'
50
+ ])
51
+ lines.join("\n")
52
+ end
53
+
16
54
  class_option :no_interactive, type: :boolean, default: false, desc: "Disable interactive prompts (auto-detected when not a TTY)"
17
55
  class_option :interactive, type: :boolean, default: false, desc: "Force interactive mode even when not a TTY"
18
56
  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"
57
+ class_option :dry_run, type: :boolean, default: false, desc: "Simulate writes while allowing read lookups"
20
58
 
21
59
  no_commands do
22
60
  def invoke_command(command, *args)
@@ -124,6 +162,7 @@ module FreshBooks
124
162
  if status_data["tokens_exist"]
125
163
  puts "Expired: #{status_data["tokens_expired"] ? "yes" : "no"}"
126
164
  end
165
+ puts "Requires re-auth: #{status_data["requires_reauth"] ? "yes" : "no"}"
127
166
  puts "Business ID: #{status_data["business_id"] || "not set"}"
128
167
  puts "Account ID: #{status_data["account_id"] || "not set"}"
129
168
  end
@@ -210,26 +249,44 @@ module FreshBooks
210
249
 
211
250
  # --- log ---
212
251
 
213
- desc "log", "Log a time entry"
252
+ desc "log [--client NAME] [--project NAME] [--service NAME] --duration HOURS --note TEXT [--date YYYY-MM-DD] [--internal] [--yes]",
253
+ "Log a time entry"
254
+ long_desc <<~DESC
255
+ Log a FreshBooks time entry.
256
+
257
+ Non-interactive usage requires --duration and --note. Use --client when multiple clients exist, or --internal with --project for internal work.
258
+
259
+ Examples:
260
+ fb log --project "Sample Project" --service "Development" --duration 0.5 --note "Reviewed pull requests" --yes
261
+ fb log --internal --project "Admin" --hours 1 --notes "Planning" --yes
262
+ DESC
214
263
  method_option :client, type: :string, desc: "Pre-select client by name"
215
264
  method_option :project, type: :string, desc: "Pre-select project by name"
216
265
  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"
266
+ method_option :duration, type: :numeric, aliases: "--hours", desc: "Duration in hours (e.g. 2.5)"
267
+ method_option :note, type: :string, aliases: "--notes", desc: "Work description"
219
268
  method_option :date, type: :string, desc: "Date (YYYY-MM-DD, defaults to today)"
269
+ method_option :internal, type: :boolean, default: false, desc: "Log to an internal project with no client"
220
270
  method_option :yes, type: :boolean, default: false, desc: "Skip confirmation"
271
+ method_option :help, type: :boolean, desc: "Show command help"
221
272
  def log
273
+ if options[:help]
274
+ self.class.command_help(shell, "log")
275
+ return
276
+ end
277
+
222
278
  Auth.valid_access_token
223
279
  defaults = Auth.load_defaults
224
280
 
225
- client = select_client(defaults)
226
- project = select_project(client["id"], defaults)
281
+ context = resolve_log_context(defaults)
282
+ client = context[:client]
283
+ project = context[:project]
227
284
  service = select_service(defaults, project)
228
285
  date = pick_date
229
286
  duration_hours = pick_duration
230
287
  note = pick_note
231
288
 
232
- client_name = display_name(client)
289
+ client_name = client ? display_name(client) : "Internal"
233
290
 
234
291
  unless options[:format] == "json"
235
292
  puts "\n--- Time Entry Summary ---"
@@ -252,9 +309,9 @@ module FreshBooks
252
309
  "is_logged" => true,
253
310
  "duration" => (duration_hours * 3600).to_i,
254
311
  "note" => note,
255
- "started_at" => normalize_datetime(date),
256
- "client_id" => client["id"]
312
+ "started_at" => normalize_datetime(date)
257
313
  }
314
+ entry["client_id"] = client["id"] if client
258
315
  entry["project_id"] = project["id"] if project
259
316
  entry["service_id"] = service["id"] if service
260
317
 
@@ -266,7 +323,8 @@ module FreshBooks
266
323
  puts "Time entry created!"
267
324
  end
268
325
 
269
- new_defaults = { "client_id" => client["id"] }
326
+ new_defaults = {}
327
+ new_defaults["client_id"] = client["id"] if client
270
328
  new_defaults["project_id"] = project["id"] if project
271
329
  new_defaults["service_id"] = service["id"] if service
272
330
  Auth.save_defaults(new_defaults)
@@ -328,7 +386,7 @@ module FreshBooks
328
386
 
329
387
  rows = entries.map do |e|
330
388
  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
389
+ client = display_client_name(e["client_id"], maps)
332
390
  project = maps[:projects][e["project_id"].to_s] || "-"
333
391
  service = maps[:services][e["service_id"].to_s] || "-"
334
392
  note = e["note"] || ""
@@ -341,7 +399,7 @@ module FreshBooks
341
399
  total = entries.sum { |e| e["duration"].to_i } / 3600.0
342
400
 
343
401
  # Per-client breakdown
344
- by_client = entries.group_by { |e| maps[:clients][e["client_id"].to_s] || e["client_id"].to_s }
402
+ by_client = entries.group_by { |e| display_client_name(e["client_id"], maps) }
345
403
  if by_client.length > 1
346
404
  puts "\nBy client:"
347
405
  by_client.sort_by { |_, es| -es.sum { |e| e["duration"].to_i } }.each do |name, es|
@@ -416,7 +474,7 @@ module FreshBooks
416
474
  end
417
475
 
418
476
  rows = projects.map do |p|
419
- client_name = maps[:clients][p["client_id"].to_s] || "-"
477
+ client_name = display_project_client_name(p, maps)
420
478
  [p["title"], client_name, p["active"] ? "active" : "inactive"]
421
479
  end
422
480
 
@@ -514,12 +572,13 @@ module FreshBooks
514
572
 
515
573
  desc "edit", "Edit a time entry"
516
574
  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"
575
+ method_option :duration, type: :numeric, aliases: "--hours", desc: "New duration in hours"
576
+ method_option :note, type: :string, aliases: "--notes", desc: "New note"
519
577
  method_option :date, type: :string, desc: "New date (YYYY-MM-DD)"
520
578
  method_option :client, type: :string, desc: "New client name"
521
579
  method_option :project, type: :string, desc: "New project name"
522
580
  method_option :service, type: :string, desc: "New service name"
581
+ method_option :internal, type: :boolean, default: false, desc: "Move entry to an internal project with no client"
523
582
  method_option :yes, type: :boolean, default: false, desc: "Skip confirmation"
524
583
  def edit
525
584
  Auth.valid_access_token
@@ -535,7 +594,7 @@ module FreshBooks
535
594
  abort("Time entry not found.") unless entry
536
595
 
537
596
  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]
597
+ has_edit_flags = options[:duration] || options[:note] || options[:date] || options[:client] || options[:project] || options[:service] || options[:internal]
539
598
  scripted = has_edit_flags || !interactive?
540
599
 
541
600
  fields = build_edit_fields(entry, maps, scripted)
@@ -544,11 +603,18 @@ module FreshBooks
544
603
  current_project = maps[:projects][entry["project_id"].to_s] || "-"
545
604
  current_hours = (entry["duration"].to_i / 3600.0).round(2)
546
605
  new_hours = fields["duration"] ? (fields["duration"].to_i / 3600.0).round(2) : current_hours
606
+ summary_client = if fields.key?("client_id")
607
+ fields["client_id"] ? maps[:clients][fields["client_id"].to_s] : "Internal"
608
+ elsif fields["project_id"] != entry["project_id"]
609
+ "Internal"
610
+ else
611
+ current_client
612
+ end
547
613
 
548
614
  unless options[:format] == "json"
549
615
  puts "\n--- Edit Summary ---"
550
616
  puts " Date: #{fields["started_at"] || entry["started_at"]}"
551
- puts " Client: #{fields["client_id"] ? maps[:clients][fields["client_id"].to_s] : current_client}"
617
+ puts " Client: #{summary_client}"
552
618
  puts " Project: #{fields["project_id"] ? maps[:projects][fields["project_id"].to_s] : current_project}"
553
619
  puts " Duration: #{new_hours}h"
554
620
  puts " Note: #{fields["note"] || entry["note"]}"
@@ -646,6 +712,63 @@ module FreshBooks
646
712
  $stdin.tty?
647
713
  end
648
714
 
715
+ def resolve_log_context(defaults)
716
+ validate_internal_log_options!
717
+
718
+ if options[:project] && (!options[:client] || options[:internal])
719
+ project = resolve_project_by_name(options[:project], force: true)
720
+ ensure_project_matches_internal_option!(project)
721
+ client = project_internal?(project) ? nil : resolve_client_for_project(project)
722
+ return { client: client, project: project }
723
+ end
724
+
725
+ client = select_client(defaults)
726
+ project = select_project(client["id"], defaults)
727
+ { client: client, project: project }
728
+ end
729
+
730
+ def validate_internal_log_options!
731
+ abort("Cannot combine --client with --internal.") if options[:client] && options[:internal]
732
+ abort("--internal requires --project.") if options[:internal] && !options[:project]
733
+ end
734
+
735
+ def validate_internal_edit_options!
736
+ abort("Cannot combine --client with --internal.") if options[:client] && options[:internal]
737
+ abort("--internal requires --project.") if options[:internal] && !options[:project]
738
+ end
739
+
740
+ def resolve_project_by_name(name, force: false)
741
+ projects = Spinner.spin("Fetching projects") { Api.fetch_projects(force: force) }
742
+ match = projects.find { |project| project["title"].downcase == name.downcase }
743
+ abort("Project not found: #{name}") unless match
744
+ match
745
+ end
746
+
747
+ def project_internal?(project)
748
+ project["internal"] || project["client_id"].nil?
749
+ end
750
+
751
+ def display_client_name(client_id, maps)
752
+ return "Internal" if client_id.nil?
753
+ maps[:clients][client_id.to_s] || client_id.to_s
754
+ end
755
+
756
+ def display_project_client_name(project, maps)
757
+ return "Internal" if project_internal?(project)
758
+ maps[:clients][project["client_id"].to_s] || "-"
759
+ end
760
+
761
+ def ensure_project_matches_internal_option!(project)
762
+ abort("Project #{project["title"]} is not internal.") if options[:internal] && !project_internal?(project)
763
+ end
764
+
765
+ def resolve_client_for_project(project)
766
+ clients = Spinner.spin("Fetching clients") { Api.fetch_clients }
767
+ match = clients.find { |client| client["id"].to_i == project["client_id"].to_i }
768
+ abort("Client not found for project: #{project["title"]}") unless match
769
+ match
770
+ end
771
+
649
772
  def select_client(defaults)
650
773
  clients = Spinner.spin("Fetching clients") { Api.fetch_clients }
651
774
 
@@ -865,7 +988,7 @@ module FreshBooks
865
988
 
866
989
  grouped = {}
867
990
  entries.each do |e|
868
- client = maps[:clients][e["client_id"].to_s] || e["client_id"].to_s
991
+ client = display_client_name(e["client_id"], maps)
869
992
  project = maps[:projects][e["project_id"].to_s] || "-"
870
993
  key = "#{client} / #{project}"
871
994
  grouped[key] ||= 0.0
@@ -884,7 +1007,7 @@ module FreshBooks
884
1007
  entry_data = entries.map do |e|
885
1008
  {
886
1009
  "id" => e["id"],
887
- "client" => maps[:clients][e["client_id"].to_s] || e["client_id"].to_s,
1010
+ "client" => display_client_name(e["client_id"], maps),
888
1011
  "project" => maps[:projects][e["project_id"].to_s] || "-",
889
1012
  "duration" => e["duration"],
890
1013
  "hours" => (e["duration"].to_i / 3600.0).round(2),
@@ -907,7 +1030,7 @@ module FreshBooks
907
1030
 
908
1031
  puts "\nToday's entries:\n\n"
909
1032
  entries.each_with_index do |e, i|
910
- client = maps[:clients][e["client_id"].to_s] || e["client_id"].to_s
1033
+ client = display_client_name(e["client_id"], maps)
911
1034
  hours = (e["duration"].to_i / 3600.0).round(2)
912
1035
  note = (e["note"] || "").slice(0, 40)
913
1036
  puts " #{i + 1}. [#{e["id"]}] #{client} — #{hours}h — #{note}"
@@ -935,22 +1058,26 @@ module FreshBooks
935
1058
  }
936
1059
 
937
1060
  if scripted
1061
+ validate_internal_edit_options!
938
1062
  fields["duration"] = (options[:duration] * 3600).to_i if options[:duration]
939
1063
  fields["note"] = options[:note] if options[:note]
940
1064
  fields["started_at"] = normalize_datetime(options[:date]) if options[:date]
941
1065
 
942
- if options[:client]
1066
+ if options[:project]
1067
+ project = resolve_project_by_name(options[:project], force: true)
1068
+ ensure_project_matches_internal_option!(project)
1069
+ fields["project_id"] = project["id"]
1070
+ if project_internal?(project)
1071
+ fields.delete("client_id")
1072
+ else
1073
+ fields["client_id"] = project["client_id"].to_i
1074
+ end
1075
+ elsif options[:client]
943
1076
  client_id = maps[:clients].find { |_id, name| name.downcase == options[:client].downcase }&.first
944
1077
  abort("Client not found: #{options[:client]}") unless client_id
945
1078
  fields["client_id"] = client_id.to_i
946
1079
  end
947
1080
 
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
1081
  if options[:service]
955
1082
  service_id = maps[:services].find { |_id, name| name.downcase == options[:service].downcase }&.first
956
1083
  abort("Service not found: #{options[:service]}") unless service_id
@@ -994,7 +1121,7 @@ module FreshBooks
994
1121
  global_flags: {
995
1122
  "--no-interactive" => "Disable interactive prompts (auto-detected when not a TTY)",
996
1123
  "--format json" => "Output format: json (available on all commands)",
997
- "--dry-run" => "Simulate command without making network calls (writes skipped)"
1124
+ "--dry-run" => "Simulate writes while allowing read lookups"
998
1125
  },
999
1126
  commands: {
1000
1127
  auth: {
@@ -1023,12 +1150,13 @@ module FreshBooks
1023
1150
  usage: "fb log",
1024
1151
  interactive: "Prompts for missing fields when interactive; requires --duration and --note when non-interactive",
1025
1152
  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)",
1153
+ "--client" => "Client name (required only for client-backed resolution when multiple clients exist)",
1154
+ "--project" => "Project name (can be internal; internal projects may be used without --client)",
1155
+ "--service" => "Service name (project-scoped; optional if single/default)",
1029
1156
  "--duration" => "Duration in hours, e.g. 2.5 (required non-interactive)",
1030
1157
  "--note" => "Work description (required non-interactive)",
1031
1158
  "--date" => "Date YYYY-MM-DD (defaults to today)",
1159
+ "--internal" => "Force internal-project resolution; requires --project and conflicts with --client",
1032
1160
  "--yes" => "Skip confirmation prompt"
1033
1161
  }
1034
1162
  },
@@ -1080,8 +1208,9 @@ module FreshBooks
1080
1208
  "--note" => "New note",
1081
1209
  "--date" => "New date (YYYY-MM-DD)",
1082
1210
  "--client" => "New client name",
1083
- "--project" => "New project name",
1211
+ "--project" => "New project name (internal projects omit client_id)",
1084
1212
  "--service" => "New service name",
1213
+ "--internal" => "Force internal-project resolution; requires --project and conflicts with --client",
1085
1214
  "--yes" => "Skip confirmation prompt"
1086
1215
  }
1087
1216
  },
@@ -2,6 +2,6 @@
2
2
 
3
3
  module FreshBooks
4
4
  module CLI
5
- VERSION = "0.4.0"
5
+ VERSION = "0.5.1"
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: freshbooks-cli
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.0
4
+ version: 0.5.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - parasquid
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-04-10 00:00:00.000000000 Z
11
+ date: 2026-05-12 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: thor