freshbooks-cli 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 95248b0321e95683bfd6cb73a21d3b4bf4bf3e9c07f731ffddc20bf4752b717b
4
- data.tar.gz: 8ce4627953d5e1c53a7100bbf1aee166b020b503e232163c9efab80a2f9cf2b2
3
+ metadata.gz: 7d8d73d99463668eb02e32550fe31e0c0ee32f616bd8fbfd9839df01f5a22052
4
+ data.tar.gz: ce0758e59c4e5631b8d84c87d8c56bdb63e87386021e27de32922e162d0dad55
5
5
  SHA512:
6
- metadata.gz: 8eedf01766a0d76d25ff123d6009558d7c224756c9d32ab0ee397a8007e7a357859b4361b86f2acafe9aa2e9a38db2267f6c7ba881c7ceb1fb66e331f77dd2cb
7
- data.tar.gz: a4f63fa93b1247d92907b3bc7d467778d9a6c5b34f6811a0a739b670e06ddd5876dbe2fa460210e4572ebcfcc4c52c7e2d2a4f22e043db780b14e0981ec7af3d
6
+ metadata.gz: d21f0dd4f0d9704e736aa41b2f574c9d616e097d05a61d5e7eb23a6d39730cc9cb291178381397563e34e71fdbae7716ba25af2d8c34b4fe1da3b409f2ebc56f
7
+ data.tar.gz: ab9dcae6c63603ddc32aeb4519c6e4022eaa406e53a02293e1d5f7cf7c7546a1f1f3017194b3498bbcfd8de3da03be48b20ac039d8c587b6c15ff4aa7eff11a3
data/lib/fb/api.rb CHANGED
@@ -71,18 +71,52 @@ module FB
71
71
  all_items
72
72
  end
73
73
 
74
+ # --- Cache helpers ---
75
+
76
+ def cache_fresh?
77
+ cache = Auth.load_cache
78
+ cache["updated_at"] && (Time.now.to_i - cache["updated_at"]) < 600
79
+ end
80
+
81
+ def cached_data(key)
82
+ cache = Auth.load_cache
83
+ return nil unless cache["updated_at"] && (Time.now.to_i - cache["updated_at"]) < 600
84
+ cache[key]
85
+ end
86
+
87
+ def update_cache(key, data)
88
+ cache = Auth.load_cache
89
+ cache["updated_at"] = Time.now.to_i
90
+ cache[key] = data
91
+ Auth.save_cache(cache)
92
+ end
93
+
74
94
  # --- Clients ---
75
95
 
76
- def fetch_clients
96
+ def fetch_clients(force: false)
97
+ unless force
98
+ cached = cached_data("clients_data")
99
+ return cached if cached
100
+ end
101
+
77
102
  url = "#{BASE}/accounting/account/#{account_id}/users/clients"
78
- fetch_all_pages(url, "clients")
103
+ results = fetch_all_pages(url, "clients")
104
+ update_cache("clients_data", results)
105
+ results
79
106
  end
80
107
 
81
108
  # --- Projects ---
82
109
 
83
- def fetch_projects
110
+ def fetch_projects(force: false)
111
+ unless force
112
+ cached = cached_data("projects_data")
113
+ return cached if cached
114
+ end
115
+
84
116
  url = "#{BASE}/projects/business/#{business_id}/projects"
85
- fetch_all_pages(url, "projects")
117
+ results = fetch_all_pages(url, "projects")
118
+ update_cache("projects_data", results)
119
+ results
86
120
  end
87
121
 
88
122
  def fetch_projects_for_client(client_id)
@@ -92,7 +126,12 @@ module FB
92
126
 
93
127
  # --- Services ---
94
128
 
95
- def fetch_services
129
+ def fetch_services(force: false)
130
+ unless force
131
+ cached = cached_data("services_data")
132
+ return cached if cached
133
+ end
134
+
96
135
  url = "#{BASE}/comments/business/#{business_id}/services"
97
136
  response = HTTParty.get(url, { headers: headers })
98
137
 
@@ -104,7 +143,9 @@ module FB
104
143
 
105
144
  data = response.parsed_response
106
145
  services_hash = data.dig("result", "services") || {}
107
- services_hash.values
146
+ results = services_hash.values
147
+ update_cache("services_data", results)
148
+ results
108
149
  end
109
150
 
110
151
  # --- Time Entries ---
@@ -117,6 +158,20 @@ module FB
117
158
  fetch_all_pages(url, "time_entries", params: params)
118
159
  end
119
160
 
161
+ def fetch_time_entry(entry_id)
162
+ url = "#{BASE}/timetracking/business/#{business_id}/time_entries/#{entry_id}"
163
+ response = HTTParty.get(url, { headers: headers })
164
+
165
+ unless response.success?
166
+ body = response.parsed_response
167
+ msg = extract_error(body) || response.body
168
+ abort("API error: #{msg}")
169
+ end
170
+
171
+ data = response.parsed_response
172
+ data.dig("result", "time_entry") || data.dig("time_entry")
173
+ end
174
+
120
175
  def create_time_entry(entry)
121
176
  url = "#{BASE}/timetracking/business/#{business_id}/time_entries"
122
177
  body = { time_entry: entry }
@@ -135,21 +190,56 @@ module FB
135
190
  response.parsed_response
136
191
  end
137
192
 
193
+ def update_time_entry(entry_id, fields)
194
+ url = "#{BASE}/timetracking/business/#{business_id}/time_entries/#{entry_id}"
195
+ body = { time_entry: fields }
196
+
197
+ response = HTTParty.put(url, {
198
+ headers: headers,
199
+ body: body.to_json
200
+ })
201
+
202
+ unless response.success?
203
+ body = response.parsed_response
204
+ msg = extract_error(body) || response.body
205
+ abort("API error: #{msg}")
206
+ end
207
+
208
+ response.parsed_response
209
+ end
210
+
211
+ def delete_time_entry(entry_id)
212
+ url = "#{BASE}/timetracking/business/#{business_id}/time_entries/#{entry_id}"
213
+
214
+ response = HTTParty.delete(url, { headers: headers })
215
+
216
+ unless response.success?
217
+ body = response.parsed_response
218
+ msg = extract_error(body) || response.body
219
+ abort("API error: #{msg}")
220
+ end
221
+
222
+ true
223
+ end
224
+
138
225
  # --- Name Resolution (for entries display) ---
139
226
 
140
227
  def build_name_maps
141
228
  cache = Auth.load_cache
142
229
  now = Time.now.to_i
143
230
 
144
- if cache["updated_at"] && (now - cache["updated_at"]) < 600
231
+ if cache["updated_at"] && (now - cache["updated_at"]) < 600 &&
232
+ cache["clients"] && !cache["clients"].empty?
145
233
  return {
146
234
  clients: (cache["clients"] || {}),
147
- projects: (cache["projects"] || {})
235
+ projects: (cache["projects"] || {}),
236
+ services: (cache["services"] || {})
148
237
  }
149
238
  end
150
239
 
151
- clients = fetch_clients
152
- projects = fetch_projects
240
+ clients = fetch_clients(force: true)
241
+ projects = fetch_projects(force: true)
242
+ services = fetch_services(force: true)
153
243
 
154
244
  client_map = {}
155
245
  clients.each do |c|
@@ -163,14 +253,26 @@ module FB
163
253
  project_map[p["id"].to_s] = p["title"]
164
254
  end
165
255
 
166
- cache_data = {
167
- "updated_at" => now,
168
- "clients" => client_map,
169
- "projects" => project_map
170
- }
171
- Auth.save_cache(cache_data)
256
+ service_map = {}
257
+ services.each do |s|
258
+ service_map[s["id"].to_s] = s["name"]
259
+ end
260
+
261
+ # Also collect services embedded in projects
262
+ projects.each do |p|
263
+ (p["services"] || []).each do |s|
264
+ service_map[s["id"].to_s] ||= s["name"]
265
+ end
266
+ end
267
+
268
+ cache = Auth.load_cache
269
+ cache["updated_at"] = now
270
+ cache["clients"] = client_map
271
+ cache["projects"] = project_map
272
+ cache["services"] = service_map
273
+ Auth.save_cache(cache)
172
274
 
173
- { clients: client_map, projects: project_map }
275
+ { clients: client_map, projects: project_map, services: service_map }
174
276
  end
175
277
 
176
278
  private
data/lib/fb/auth.rb CHANGED
@@ -94,6 +94,55 @@ module FB
94
94
  config
95
95
  end
96
96
 
97
+ def setup_config_from_args(client_id, client_secret)
98
+ abort("Missing --client-id") if client_id.nil? || client_id.empty?
99
+ abort("Missing --client-secret") if client_secret.nil? || client_secret.empty?
100
+
101
+ config = { "client_id" => client_id, "client_secret" => client_secret }
102
+ save_config(config)
103
+ config
104
+ end
105
+
106
+ def authorize_url(config)
107
+ "#{AUTH_URL}?client_id=#{config["client_id"]}&response_type=code&redirect_uri=#{URI.encode_www_form_component(REDIRECT_URI)}"
108
+ end
109
+
110
+ def extract_code_from_url(redirect_url)
111
+ uri = URI.parse(redirect_url)
112
+ params = URI.decode_www_form(uri.query || "").to_h
113
+ params["code"]
114
+ end
115
+
116
+ def auth_status
117
+ config = load_config
118
+ tokens = load_tokens
119
+ {
120
+ "config_exists" => !config.nil?,
121
+ "config_path" => config_path,
122
+ "tokens_exist" => !tokens.nil?,
123
+ "tokens_expired" => tokens ? token_expired?(tokens) : nil,
124
+ "business_id" => config&.dig("business_id"),
125
+ "account_id" => config&.dig("account_id")
126
+ }
127
+ end
128
+
129
+ def fetch_businesses(access_token)
130
+ identity = fetch_identity(access_token)
131
+ memberships = identity.dig("business_memberships") || []
132
+ memberships.select { |m| m.dig("business", "account_id") }
133
+ end
134
+
135
+ def select_business(config, business_id, businesses)
136
+ selected = businesses.find { |m| m.dig("business", "id").to_s == business_id.to_s }
137
+ abort("Business not found: #{business_id}. Available: #{businesses.map { |m| "#{m.dig("business", "name")} (#{m.dig("business", "id")})" }.join(", ")}") unless selected
138
+
139
+ biz = selected["business"]
140
+ config["business_id"] = biz["id"]
141
+ config["account_id"] = biz["account_id"]
142
+ save_config(config)
143
+ config
144
+ end
145
+
97
146
  def require_config
98
147
  config = load_config
99
148
  return config if config
data/lib/fb/cli.rb CHANGED
@@ -10,14 +10,165 @@ module FB
10
10
  true
11
11
  end
12
12
 
13
+ class_option :no_interactive, type: :boolean, default: false, desc: "Disable interactive prompts (auto-detected when not a TTY)"
14
+ class_option :format, type: :string, desc: "Output format: table (default) or json"
15
+
16
+ # --- version ---
17
+
18
+ desc "version", "Print the current version"
19
+ def version
20
+ puts "freshbooks-cli #{VERSION}"
21
+ end
22
+
13
23
  # --- auth ---
14
24
 
15
- desc "auth", "Authenticate with FreshBooks via OAuth2"
16
- def auth
17
- config = Auth.require_config
18
- tokens = Auth.authorize(config)
19
- Auth.discover_business(tokens["access_token"], config)
20
- puts "\nReady to go! Try: fb entries"
25
+ desc "auth [SUBCOMMAND] [ARGS]", "Authenticate with FreshBooks via OAuth2 (subcommands: setup, url, callback, status)"
26
+ method_option :client_id, type: :string, desc: "OAuth client ID (for setup)"
27
+ method_option :client_secret, type: :string, desc: "OAuth client secret (for setup)"
28
+ def auth(subcommand = nil, *args)
29
+ case subcommand
30
+ when "setup"
31
+ config = Auth.setup_config_from_args(options[:client_id], options[:client_secret])
32
+ if options[:format] == "json"
33
+ puts JSON.pretty_generate({ "config_path" => Auth.config_path, "status" => "saved" })
34
+ else
35
+ puts "Config saved to #{Auth.config_path}"
36
+ end
37
+
38
+ when "url"
39
+ config = Auth.load_config
40
+ abort("No config found. Run: fb auth setup --client-id ID --client-secret SECRET") unless config
41
+ url = Auth.authorize_url(config)
42
+ if options[:format] == "json"
43
+ puts JSON.pretty_generate({ "url" => url })
44
+ else
45
+ puts url
46
+ end
47
+
48
+ when "callback"
49
+ config = Auth.load_config
50
+ abort("No config found. Run: fb auth setup --client-id ID --client-secret SECRET") unless config
51
+ redirect_url = args.first
52
+ abort("Usage: fb auth callback REDIRECT_URL") unless redirect_url
53
+ code = Auth.extract_code_from_url(redirect_url)
54
+ abort("Could not find 'code' parameter in the URL.") unless code
55
+ tokens = Auth.exchange_code(config, code)
56
+
57
+ # Auto-discover businesses
58
+ businesses = Auth.fetch_businesses(tokens["access_token"])
59
+ if businesses.length == 1
60
+ Auth.select_business(config, businesses.first.dig("business", "id"), businesses)
61
+ biz = businesses.first["business"]
62
+ if options[:format] == "json"
63
+ puts JSON.pretty_generate({ "status" => "authenticated", "business" => biz })
64
+ else
65
+ puts "Business auto-selected: #{biz["name"]} (#{biz["id"]})"
66
+ end
67
+ else
68
+ if options[:format] == "json"
69
+ biz_list = businesses.map { |m| m["business"] }
70
+ puts JSON.pretty_generate({ "status" => "authenticated", "businesses" => biz_list, "business_selected" => false })
71
+ else
72
+ puts "Authenticated! Multiple businesses found — select one with: fb business --select ID"
73
+ businesses.each do |m|
74
+ biz = m["business"]
75
+ puts " #{biz["name"]} (ID: #{biz["id"]})"
76
+ end
77
+ end
78
+ end
79
+
80
+ when "status"
81
+ status_data = Auth.auth_status
82
+ if options[:format] == "json"
83
+ puts JSON.pretty_generate(status_data)
84
+ else
85
+ puts "Config: #{status_data["config_exists"] ? "found" : "missing"} (#{status_data["config_path"]})"
86
+ puts "Tokens: #{status_data["tokens_exist"] ? "found" : "missing"}"
87
+ if status_data["tokens_exist"]
88
+ puts "Expired: #{status_data["tokens_expired"] ? "yes" : "no"}"
89
+ end
90
+ puts "Business ID: #{status_data["business_id"] || "not set"}"
91
+ puts "Account ID: #{status_data["account_id"] || "not set"}"
92
+ end
93
+
94
+ else
95
+ unless interactive?
96
+ abort("Use auth subcommands for non-interactive auth: fb auth setup, fb auth url, fb auth callback, fb auth status")
97
+ end
98
+ config = Auth.require_config
99
+ tokens = Auth.authorize(config)
100
+ Auth.discover_business(tokens["access_token"], config)
101
+ puts "\nReady to go! Try: fb entries"
102
+ end
103
+ end
104
+
105
+ # --- business ---
106
+
107
+ desc "business", "List or select a business"
108
+ method_option :select, type: :string, desc: "Set active business by ID (omit value for interactive picker)"
109
+ def business
110
+ Auth.valid_access_token
111
+ config = Auth.load_config
112
+ tokens = Auth.load_tokens
113
+ businesses = Auth.fetch_businesses(tokens["access_token"])
114
+
115
+ if businesses.empty?
116
+ abort("No business memberships found on your FreshBooks account.")
117
+ end
118
+
119
+ if options[:select]
120
+ Auth.select_business(config, options[:select], businesses)
121
+ selected = businesses.find { |m| m.dig("business", "id").to_s == options[:select].to_s }
122
+ biz = selected["business"]
123
+ if options[:format] == "json"
124
+ puts JSON.pretty_generate(biz)
125
+ else
126
+ puts "Active business: #{biz["name"]} (#{biz["id"]})"
127
+ end
128
+ return
129
+ end
130
+
131
+ if options.key?("select") && options[:select].nil?
132
+ # --select with no value: interactive picker
133
+ unless interactive?
134
+ abort("Non-interactive: use --select ID. Available businesses:\n" +
135
+ businesses.map { |m| " #{m.dig("business", "name")} (ID: #{m.dig("business", "id")})" }.join("\n"))
136
+ end
137
+
138
+ puts "\nBusinesses:\n\n"
139
+ businesses.each_with_index do |m, i|
140
+ biz = m["business"]
141
+ active = biz["id"].to_s == config["business_id"].to_s ? " [active]" : ""
142
+ puts " #{i + 1}. #{biz["name"]} (#{biz["id"]})#{active}"
143
+ end
144
+
145
+ print "\nSelect business (1-#{businesses.length}): "
146
+ input = $stdin.gets&.strip
147
+ abort("Cancelled.") if input.nil? || input.empty?
148
+
149
+ idx = input.to_i - 1
150
+ abort("Invalid selection.") if idx < 0 || idx >= businesses.length
151
+
152
+ selected_biz = businesses[idx]
153
+ Auth.select_business(config, selected_biz.dig("business", "id"), businesses)
154
+ puts "Active business: #{selected_biz.dig("business", "name")} (#{selected_biz.dig("business", "id")})"
155
+ return
156
+ end
157
+
158
+ # Default: list businesses
159
+ if options[:format] == "json"
160
+ biz_list = businesses.map do |m|
161
+ biz = m["business"]
162
+ biz.merge("active" => biz["id"].to_s == config["business_id"].to_s)
163
+ end
164
+ puts JSON.pretty_generate(biz_list)
165
+ else
166
+ businesses.each do |m|
167
+ biz = m["business"]
168
+ active = biz["id"].to_s == config["business_id"].to_s ? " [active]" : ""
169
+ puts "#{biz["name"]} (ID: #{biz["id"]})#{active}"
170
+ end
171
+ end
21
172
  end
22
173
 
23
174
  # --- log ---
@@ -33,25 +184,26 @@ module FB
33
184
  def log
34
185
  Auth.valid_access_token
35
186
  defaults = Auth.load_defaults
36
- interactive = !(options[:client] && options[:duration] && options[:note])
37
187
 
38
- client = select_client(defaults, interactive)
39
- project = select_project(client["id"], defaults, interactive)
40
- service = select_service(defaults, interactive)
41
- date = pick_date(interactive)
42
- duration_hours = pick_duration(interactive)
43
- note = pick_note(interactive)
188
+ client = select_client(defaults)
189
+ project = select_project(client["id"], defaults)
190
+ service = select_service(defaults, project)
191
+ date = pick_date
192
+ duration_hours = pick_duration
193
+ note = pick_note
44
194
 
45
195
  client_name = display_name(client)
46
196
 
47
- puts "\n--- Time Entry Summary ---"
48
- puts " Client: #{client_name}"
49
- puts " Project: #{project ? project["title"] : "(none)"}"
50
- puts " Service: #{service ? service["name"] : "(none)"}"
51
- puts " Date: #{date}"
52
- puts " Duration: #{duration_hours}h"
53
- puts " Note: #{note}"
54
- puts "--------------------------\n\n"
197
+ unless options[:format] == "json"
198
+ puts "\n--- Time Entry Summary ---"
199
+ puts " Client: #{client_name}"
200
+ puts " Project: #{project ? project["title"] : "(none)"}"
201
+ puts " Service: #{service ? service["name"] : "(none)"}"
202
+ puts " Date: #{date}"
203
+ puts " Duration: #{duration_hours}h"
204
+ puts " Note: #{note}"
205
+ puts "--------------------------\n\n"
206
+ end
55
207
 
56
208
  unless options[:yes]
57
209
  print "Submit? (Y/n): "
@@ -63,14 +215,19 @@ module FB
63
215
  "is_logged" => true,
64
216
  "duration" => (duration_hours * 3600).to_i,
65
217
  "note" => note,
66
- "started_at" => date,
218
+ "started_at" => normalize_datetime(date),
67
219
  "client_id" => client["id"]
68
220
  }
69
221
  entry["project_id"] = project["id"] if project
70
222
  entry["service_id"] = service["id"] if service
71
223
 
72
- Api.create_time_entry(entry)
73
- puts "Time entry created!"
224
+ result = Api.create_time_entry(entry)
225
+
226
+ if options[:format] == "json"
227
+ puts JSON.pretty_generate(result)
228
+ else
229
+ puts "Time entry created!"
230
+ end
74
231
 
75
232
  new_defaults = { "client_id" => client["id"] }
76
233
  new_defaults["project_id"] = project["id"] if project
@@ -85,7 +242,6 @@ module FB
85
242
  method_option :year, type: :numeric, desc: "Year"
86
243
  method_option :from, type: :string, desc: "Start date (YYYY-MM-DD)"
87
244
  method_option :to, type: :string, desc: "End date (YYYY-MM-DD)"
88
- method_option :format, type: :string, default: "table", desc: "Output format: table or json"
89
245
  def entries
90
246
  Auth.valid_access_token
91
247
 
@@ -117,7 +273,11 @@ module FB
117
273
  end
118
274
 
119
275
  if entries.empty?
120
- puts "No time entries#{label ? " #{label}" : ""}."
276
+ if options[:format] == "json"
277
+ puts "[]"
278
+ else
279
+ puts "No time entries#{label ? " #{label}" : ""}."
280
+ end
121
281
  return
122
282
  end
123
283
 
@@ -133,29 +293,287 @@ module FB
133
293
  date = e["started_at"] || "?"
134
294
  client = maps[:clients][e["client_id"].to_s] || e["client_id"].to_s
135
295
  project = maps[:projects][e["project_id"].to_s] || "-"
296
+ service = maps[:services][e["service_id"].to_s] || "-"
136
297
  note = (e["note"] || "").slice(0, 50)
137
298
  hours = (e["duration"].to_i / 3600.0).round(2)
138
- [date, client, project, note, "#{hours}h"]
299
+ [e["id"].to_s, date, client, project, service, note, "#{hours}h"]
139
300
  end
140
301
 
141
- headers = ["Date", "Client", "Project", "Note", "Duration"]
142
- widths = headers.each_with_index.map do |h, i|
143
- [h.length, *rows.map { |r| r[i].to_s.length }].max
144
- end
145
-
146
- fmt = widths.map { |w| "%-#{w}s" }.join(" ")
147
- puts fmt % headers
148
- puts widths.map { |w| "-" * w }.join(" ")
149
- rows.each { |r| puts fmt % r }
302
+ print_table(["ID", "Date", "Client", "Project", "Service", "Note", "Duration"], rows)
150
303
 
151
304
  total = entries.sum { |e| e["duration"].to_i } / 3600.0
152
305
  puts "\nTotal: #{total.round(2)}h"
153
306
  end
154
307
 
308
+ # --- clients ---
309
+
310
+ desc "clients", "List all clients"
311
+ def clients
312
+ Auth.valid_access_token
313
+ clients = Spinner.spin("Fetching clients") { Api.fetch_clients }
314
+
315
+ if clients.empty?
316
+ puts "No clients found."
317
+ return
318
+ end
319
+
320
+ if options[:format] == "json"
321
+ puts JSON.pretty_generate(clients)
322
+ return
323
+ end
324
+
325
+ rows = clients.map do |c|
326
+ name = c["organization"]
327
+ name = "#{c["fname"]} #{c["lname"]}" if name.nil? || name.empty?
328
+ email = c["email"] || "-"
329
+ org = c["organization"] || "-"
330
+ [name, email, org]
331
+ end
332
+
333
+ print_table(["Name", "Email", "Organization"], rows)
334
+ end
335
+
336
+ # --- projects ---
337
+
338
+ desc "projects", "List all projects"
339
+ method_option :client, type: :string, desc: "Filter by client name"
340
+ def projects
341
+ Auth.valid_access_token
342
+ maps = Spinner.spin("Resolving names") { Api.build_name_maps }
343
+
344
+ projects = if options[:client]
345
+ client_id = maps[:clients].find { |_id, name| name.downcase == options[:client].downcase }&.first
346
+ abort("Client not found: #{options[:client]}") unless client_id
347
+ Spinner.spin("Fetching projects") { Api.fetch_projects_for_client(client_id) }
348
+ else
349
+ Spinner.spin("Fetching projects") { Api.fetch_projects }
350
+ end
351
+
352
+ if projects.empty?
353
+ puts "No projects found."
354
+ return
355
+ end
356
+
357
+ if options[:format] == "json"
358
+ puts JSON.pretty_generate(projects)
359
+ return
360
+ end
361
+
362
+ rows = projects.map do |p|
363
+ client_name = maps[:clients][p["client_id"].to_s] || "-"
364
+ [p["title"], client_name, p["active"] ? "active" : "inactive"]
365
+ end
366
+
367
+ print_table(["Title", "Client", "Status"], rows)
368
+ end
369
+
370
+ # --- services ---
371
+
372
+ desc "services", "List all services"
373
+ def services
374
+ Auth.valid_access_token
375
+ services = Spinner.spin("Fetching services") { Api.fetch_services }
376
+
377
+ if services.empty?
378
+ puts "No services found."
379
+ return
380
+ end
381
+
382
+ if options[:format] == "json"
383
+ puts JSON.pretty_generate(services)
384
+ return
385
+ end
386
+
387
+ rows = services.map do |s|
388
+ billable = s["billable"] ? "yes" : "no"
389
+ [s["name"], billable]
390
+ end
391
+
392
+ print_table(["Name", "Billable"], rows)
393
+ end
394
+
395
+ # --- status ---
396
+
397
+ desc "status", "Show hours summary for today, this week, and this month"
398
+ def status
399
+ Auth.valid_access_token
400
+ today = Date.today
401
+ week_start = today - ((today.wday - 1) % 7)
402
+ month_start = Date.new(today.year, today.month, 1)
403
+
404
+ entries = Spinner.spin("Fetching time entries") do
405
+ Api.fetch_time_entries(started_from: month_start.to_s, started_to: today.to_s)
406
+ end
407
+ maps = Spinner.spin("Resolving names") { Api.build_name_maps }
408
+
409
+ today_entries = entries.select { |e| e["started_at"] == today.to_s }
410
+ week_entries = entries.select { |e| d = e["started_at"]; d && d >= week_start.to_s && d <= today.to_s }
411
+ month_entries = entries
412
+
413
+ if options[:format] == "json"
414
+ puts JSON.pretty_generate({
415
+ "today" => build_status_data(today.to_s, today.to_s, today_entries, maps),
416
+ "this_week" => build_status_data(week_start.to_s, today.to_s, week_entries, maps),
417
+ "this_month" => build_status_data(month_start.to_s, today.to_s, month_entries, maps)
418
+ })
419
+ return
420
+ end
421
+
422
+ print_status_section("Today (#{today})", today_entries, maps)
423
+ print_status_section("This Week (#{week_start} to #{today})", week_entries, maps)
424
+ print_status_section("This Month (#{month_start} to #{today})", month_entries, maps)
425
+ end
426
+
427
+ # --- delete ---
428
+
429
+ desc "delete", "Delete a time entry"
430
+ method_option :id, type: :numeric, desc: "Time entry ID (skip interactive picker)"
431
+ method_option :yes, type: :boolean, default: false, desc: "Skip confirmation"
432
+ def delete
433
+ Auth.valid_access_token
434
+
435
+ if options[:id]
436
+ entry_id = options[:id]
437
+ else
438
+ abort("Missing required flag: --id") unless interactive?
439
+ entry_id = pick_entry_interactive("delete")
440
+ end
441
+
442
+ unless options[:yes]
443
+ print "Delete entry #{entry_id}? (y/N): "
444
+ answer = $stdin.gets&.strip&.downcase
445
+ abort("Cancelled.") unless answer == "y"
446
+ end
447
+
448
+ Spinner.spin("Deleting time entry") { Api.delete_time_entry(entry_id) }
449
+
450
+ if options[:format] == "json"
451
+ puts JSON.pretty_generate({ "id" => entry_id, "deleted" => true })
452
+ else
453
+ puts "Time entry #{entry_id} deleted."
454
+ end
455
+ end
456
+
457
+ # --- edit ---
458
+
459
+ desc "edit", "Edit a time entry"
460
+ method_option :id, type: :numeric, desc: "Time entry ID (skip interactive picker)"
461
+ method_option :duration, type: :numeric, desc: "New duration in hours"
462
+ method_option :note, type: :string, desc: "New note"
463
+ method_option :date, type: :string, desc: "New date (YYYY-MM-DD)"
464
+ method_option :client, type: :string, desc: "New client name"
465
+ method_option :project, type: :string, desc: "New project name"
466
+ method_option :service, type: :string, desc: "New service name"
467
+ method_option :yes, type: :boolean, default: false, desc: "Skip confirmation"
468
+ def edit
469
+ Auth.valid_access_token
470
+
471
+ if options[:id]
472
+ entry_id = options[:id]
473
+ else
474
+ abort("Missing required flag: --id") unless interactive?
475
+ entry_id = pick_entry_interactive("edit")
476
+ end
477
+
478
+ entry = Spinner.spin("Fetching time entry") { Api.fetch_time_entry(entry_id) }
479
+ abort("Time entry not found.") unless entry
480
+
481
+ maps = Spinner.spin("Resolving names") { Api.build_name_maps }
482
+ has_edit_flags = options[:duration] || options[:note] || options[:date] || options[:client] || options[:project] || options[:service]
483
+ scripted = has_edit_flags || !interactive?
484
+
485
+ fields = build_edit_fields(entry, maps, scripted)
486
+
487
+ current_client = maps[:clients][entry["client_id"].to_s] || entry["client_id"].to_s
488
+ current_project = maps[:projects][entry["project_id"].to_s] || "-"
489
+ current_hours = (entry["duration"].to_i / 3600.0).round(2)
490
+ new_hours = fields["duration"] ? (fields["duration"].to_i / 3600.0).round(2) : current_hours
491
+
492
+ unless options[:format] == "json"
493
+ puts "\n--- Edit Summary ---"
494
+ puts " Date: #{fields["started_at"] || entry["started_at"]}"
495
+ puts " Client: #{fields["client_id"] ? maps[:clients][fields["client_id"].to_s] : current_client}"
496
+ puts " Project: #{fields["project_id"] ? maps[:projects][fields["project_id"].to_s] : current_project}"
497
+ puts " Duration: #{new_hours}h"
498
+ puts " Note: #{fields["note"] || entry["note"]}"
499
+ puts "--------------------\n\n"
500
+ end
501
+
502
+ unless options[:yes]
503
+ print "Save changes? (Y/n): "
504
+ answer = $stdin.gets&.strip&.downcase
505
+ abort("Cancelled.") if answer == "n"
506
+ end
507
+
508
+ result = Spinner.spin("Updating time entry") { Api.update_time_entry(entry_id, fields) }
509
+
510
+ if options[:format] == "json"
511
+ puts JSON.pretty_generate(result)
512
+ else
513
+ puts "Time entry #{entry_id} updated."
514
+ end
515
+ end
516
+
517
+ # --- cache ---
518
+
519
+ desc "cache SUBCOMMAND", "Manage cached data (refresh, clear, status)"
520
+ def cache(subcommand = "status")
521
+ case subcommand
522
+ when "refresh"
523
+ Auth.valid_access_token
524
+ Spinner.spin("Refreshing cache") do
525
+ Api.fetch_clients(force: true)
526
+ Api.fetch_projects(force: true)
527
+ Api.fetch_services(force: true)
528
+ Api.build_name_maps
529
+ end
530
+ puts "Cache refreshed."
531
+ when "clear"
532
+ if File.exist?(Auth.cache_path)
533
+ File.delete(Auth.cache_path)
534
+ puts "Cache cleared."
535
+ else
536
+ puts "No cache file found."
537
+ end
538
+ when "status"
539
+ cache = Auth.load_cache
540
+ if options[:format] == "json"
541
+ if cache["updated_at"]
542
+ age = Time.now.to_i - cache["updated_at"]
543
+ puts JSON.pretty_generate({
544
+ "updated_at" => cache["updated_at"],
545
+ "age_seconds" => age,
546
+ "fresh" => age < 600,
547
+ "clients" => (cache["clients_data"] || []).length,
548
+ "projects" => (cache["projects_data"] || []).length,
549
+ "services" => (cache["services"] || cache["services_data"] || {}).length
550
+ })
551
+ else
552
+ puts JSON.pretty_generate({ "fresh" => false, "clients" => 0, "projects" => 0, "services" => 0 })
553
+ end
554
+ return
555
+ end
556
+
557
+ if cache["updated_at"]
558
+ age = Time.now.to_i - cache["updated_at"]
559
+ updated = Time.at(cache["updated_at"]).strftime("%Y-%m-%d %H:%M:%S")
560
+ fresh = age < 600
561
+ puts "Cache updated: #{updated}"
562
+ puts "Age: #{age}s (#{fresh ? "fresh" : "stale"})"
563
+ puts "Clients: #{(cache["clients_data"] || []).length}"
564
+ puts "Projects: #{(cache["projects_data"] || []).length}"
565
+ puts "Services: #{(cache["services"] || cache["services_data"] || {}).length}"
566
+ else
567
+ puts "No cache data."
568
+ end
569
+ else
570
+ abort("Unknown cache subcommand: #{subcommand}. Use: refresh, clear, status")
571
+ end
572
+ end
573
+
155
574
  # --- help ---
156
575
 
157
576
  desc "help [COMMAND]", "Describe available commands or one specific command"
158
- method_option :format, type: :string, desc: "Output format: text (default) or json"
159
577
  def help(command = nil)
160
578
  if options[:format] == "json"
161
579
  puts JSON.pretty_generate(help_json)
@@ -166,7 +584,12 @@ module FB
166
584
 
167
585
  private
168
586
 
169
- def select_client(defaults, interactive)
587
+ def interactive?
588
+ return false if options[:no_interactive]
589
+ $stdin.tty?
590
+ end
591
+
592
+ def select_client(defaults)
170
593
  clients = Spinner.spin("Fetching clients") { Api.fetch_clients }
171
594
 
172
595
  if options[:client]
@@ -177,6 +600,15 @@ module FB
177
600
 
178
601
  abort("No clients found.") if clients.empty?
179
602
 
603
+ unless interactive?
604
+ # Non-interactive: auto-select if single, abort with list if multiple
605
+ default_client = clients.find { |c| c["id"].to_i == defaults["client_id"].to_i }
606
+ return default_client if default_client
607
+ return clients.first if clients.length == 1
608
+ names = clients.map { |c| display_name(c) }.join(", ")
609
+ abort("Multiple clients found. Use --client to specify: #{names}")
610
+ end
611
+
180
612
  puts "\nClients:\n\n"
181
613
  clients.each_with_index do |c, i|
182
614
  name = display_name(c)
@@ -199,7 +631,7 @@ module FB
199
631
  clients[idx]
200
632
  end
201
633
 
202
- def select_project(client_id, defaults, interactive)
634
+ def select_project(client_id, defaults)
203
635
  projects = Spinner.spin("Fetching projects") { Api.fetch_projects_for_client(client_id) }
204
636
 
205
637
  if options[:project]
@@ -210,6 +642,14 @@ module FB
210
642
 
211
643
  return nil if projects.empty?
212
644
 
645
+ unless interactive?
646
+ # Non-interactive: auto-select if single, return nil if multiple (optional)
647
+ default_project = projects.find { |p| p["id"].to_i == defaults["project_id"].to_i }
648
+ return default_project if default_project
649
+ return projects.first if projects.length == 1
650
+ return nil
651
+ end
652
+
213
653
  puts "\nProjects:\n\n"
214
654
  projects.each_with_index do |p, i|
215
655
  default_marker = p["id"].to_i == defaults["project_id"].to_i ? " [default]" : ""
@@ -230,17 +670,28 @@ module FB
230
670
  projects[idx]
231
671
  end
232
672
 
233
- def select_service(defaults, interactive)
673
+ def select_service(defaults, project = nil)
674
+ # Use project-scoped services if available, fall back to global
675
+ services = if project && project["services"] && !project["services"].empty?
676
+ project["services"]
677
+ else
678
+ Spinner.spin("Fetching services") { Api.fetch_services }
679
+ end
680
+
234
681
  if options[:service]
235
- services = Spinner.spin("Fetching services") { Api.fetch_services }
236
682
  match = services.find { |s| s["name"].downcase == options[:service].downcase }
237
683
  abort("Service not found: #{options[:service]}") unless match
238
684
  return match
239
685
  end
240
686
 
241
- return nil unless interactive
687
+ unless interactive?
688
+ # Non-interactive: auto-select if single, use default if set, otherwise skip
689
+ default_service = services.find { |s| s["id"].to_i == defaults["service_id"].to_i }
690
+ return default_service if default_service
691
+ return services.first if services.length == 1
692
+ return nil
693
+ end
242
694
 
243
- services = Spinner.spin("Fetching services") { Api.fetch_services }
244
695
  return nil if services.empty?
245
696
 
246
697
  puts "\nServices:\n\n"
@@ -263,35 +714,175 @@ module FB
263
714
  services[idx]
264
715
  end
265
716
 
266
- def pick_date(interactive)
717
+ def pick_date
267
718
  return options[:date] if options[:date]
268
719
 
269
720
  today = Date.today.to_s
270
- return today unless interactive
721
+ return today unless interactive?
271
722
 
272
723
  print "\nDate [#{today}]: "
273
724
  input = $stdin.gets&.strip
274
725
  (input.nil? || input.empty?) ? today : input
275
726
  end
276
727
 
277
- def pick_duration(interactive)
728
+ def pick_duration
278
729
  return options[:duration] if options[:duration]
279
730
 
731
+ abort("Missing required flag: --duration") unless interactive?
732
+
280
733
  print "\nDuration (hours): "
281
734
  input = $stdin.gets&.strip
282
735
  abort("Duration is required.") if input.nil? || input.empty?
283
736
  input.to_f
284
737
  end
285
738
 
286
- def pick_note(interactive)
739
+ def pick_note
287
740
  return options[:note] if options[:note]
288
741
 
742
+ abort("Missing required flag: --note") unless interactive?
743
+
289
744
  print "\nNote: "
290
745
  input = $stdin.gets&.strip
291
746
  abort("Note is required.") if input.nil? || input.empty?
292
747
  input
293
748
  end
294
749
 
750
+ def print_table(headers, rows)
751
+ widths = headers.each_with_index.map do |h, i|
752
+ [h.length, *rows.map { |r| r[i].to_s.length }].max
753
+ end
754
+
755
+ fmt = widths.map { |w| "%-#{w}s" }.join(" ")
756
+ puts fmt % headers
757
+ puts widths.map { |w| "-" * w }.join(" ")
758
+ rows.each { |r| puts fmt % r }
759
+ end
760
+
761
+ def print_status_section(title, entries, maps)
762
+ puts "\n#{title}"
763
+ if entries.empty?
764
+ puts " No entries."
765
+ return
766
+ end
767
+
768
+ grouped = {}
769
+ entries.each do |e|
770
+ client = maps[:clients][e["client_id"].to_s] || e["client_id"].to_s
771
+ project = maps[:projects][e["project_id"].to_s] || "-"
772
+ key = "#{client} / #{project}"
773
+ grouped[key] ||= 0.0
774
+ grouped[key] += e["duration"].to_i / 3600.0
775
+ end
776
+
777
+ grouped.each do |key, hours|
778
+ puts " #{key}: #{hours.round(2)}h"
779
+ end
780
+
781
+ total = entries.sum { |e| e["duration"].to_i } / 3600.0
782
+ puts " Total: #{total.round(2)}h"
783
+ end
784
+
785
+ def build_status_data(from, to, entries, maps)
786
+ entry_data = entries.map do |e|
787
+ {
788
+ "id" => e["id"],
789
+ "client" => maps[:clients][e["client_id"].to_s] || e["client_id"].to_s,
790
+ "project" => maps[:projects][e["project_id"].to_s] || "-",
791
+ "duration" => e["duration"],
792
+ "hours" => (e["duration"].to_i / 3600.0).round(2),
793
+ "note" => e["note"],
794
+ "started_at" => e["started_at"]
795
+ }
796
+ end
797
+ total = entries.sum { |e| e["duration"].to_i } / 3600.0
798
+ { "from" => from, "to" => to, "entries" => entry_data, "total_hours" => total.round(2) }
799
+ end
800
+
801
+ def pick_entry_interactive(action)
802
+ today = Date.today.to_s
803
+ entries = Spinner.spin("Fetching today's entries") do
804
+ Api.fetch_time_entries(started_from: today, started_to: today)
805
+ end
806
+ abort("No entries found for today.") if entries.empty?
807
+
808
+ maps = Spinner.spin("Resolving names") { Api.build_name_maps }
809
+
810
+ puts "\nToday's entries:\n\n"
811
+ entries.each_with_index do |e, i|
812
+ client = maps[:clients][e["client_id"].to_s] || e["client_id"].to_s
813
+ hours = (e["duration"].to_i / 3600.0).round(2)
814
+ note = (e["note"] || "").slice(0, 40)
815
+ puts " #{i + 1}. [#{e["id"]}] #{client} — #{hours}h — #{note}"
816
+ end
817
+
818
+ print "\nSelect entry to #{action} (1-#{entries.length}): "
819
+ input = $stdin.gets&.strip
820
+ abort("Cancelled.") if input.nil? || input.empty?
821
+
822
+ idx = input.to_i - 1
823
+ abort("Invalid selection.") if idx < 0 || idx >= entries.length
824
+ entries[idx]["id"]
825
+ end
826
+
827
+ def build_edit_fields(entry, maps, scripted)
828
+ # FreshBooks API replaces the entry — always include all current fields
829
+ fields = {
830
+ "started_at" => entry["started_at"],
831
+ "is_logged" => entry["is_logged"] || true,
832
+ "duration" => entry["duration"],
833
+ "note" => entry["note"],
834
+ "client_id" => entry["client_id"],
835
+ "project_id" => entry["project_id"],
836
+ "service_id" => entry["service_id"]
837
+ }
838
+
839
+ if scripted
840
+ fields["duration"] = (options[:duration] * 3600).to_i if options[:duration]
841
+ fields["note"] = options[:note] if options[:note]
842
+ fields["started_at"] = normalize_datetime(options[:date]) if options[:date]
843
+
844
+ if options[:client]
845
+ client_id = maps[:clients].find { |_id, name| name.downcase == options[:client].downcase }&.first
846
+ abort("Client not found: #{options[:client]}") unless client_id
847
+ fields["client_id"] = client_id.to_i
848
+ end
849
+
850
+ if options[:project]
851
+ project_id = maps[:projects].find { |_id, name| name.downcase == options[:project].downcase }&.first
852
+ abort("Project not found: #{options[:project]}") unless project_id
853
+ fields["project_id"] = project_id.to_i
854
+ end
855
+
856
+ if options[:service]
857
+ service_id = maps[:services].find { |_id, name| name.downcase == options[:service].downcase }&.first
858
+ abort("Service not found: #{options[:service]}") unless service_id
859
+ fields["service_id"] = service_id.to_i
860
+ end
861
+ else
862
+ current_hours = (entry["duration"].to_i / 3600.0).round(2)
863
+ print "\nDuration (hours) [#{current_hours}]: "
864
+ input = $stdin.gets&.strip
865
+ fields["duration"] = (input.to_f * 3600).to_i unless input.nil? || input.empty?
866
+
867
+ current_note = entry["note"] || ""
868
+ print "Note [#{current_note}]: "
869
+ input = $stdin.gets&.strip
870
+ fields["note"] = input unless input.nil? || input.empty?
871
+
872
+ current_date = entry["started_at"] || ""
873
+ print "Date [#{current_date}]: "
874
+ input = $stdin.gets&.strip
875
+ fields["started_at"] = input unless input.nil? || input.empty?
876
+ end
877
+
878
+ fields
879
+ end
880
+
881
+ def normalize_datetime(date_str)
882
+ return date_str if date_str.include?("T")
883
+ "#{date_str}T00:00:00Z"
884
+ end
885
+
295
886
  def display_name(client)
296
887
  name = client["organization"]
297
888
  (name.nil? || name.empty?) ? "#{client["fname"]} #{client["lname"]}" : name
@@ -302,23 +893,46 @@ module FB
302
893
  name: "fb",
303
894
  description: "FreshBooks time tracking CLI",
304
895
  required_scopes: Auth::REQUIRED_SCOPES,
896
+ global_flags: {
897
+ "--no-interactive" => "Disable interactive prompts (auto-detected when not a TTY)",
898
+ "--format json" => "Output format: json (available on all commands)"
899
+ },
305
900
  commands: {
306
901
  auth: {
307
902
  description: "Authenticate with FreshBooks via OAuth2",
308
- usage: "fb auth",
309
- interactive: true
903
+ usage: "fb auth [SUBCOMMAND]",
904
+ interactive: "Interactive when no subcommand; subcommands are non-interactive",
905
+ subcommands: {
906
+ "setup" => "Save OAuth credentials: fb auth setup --client-id ID --client-secret SECRET",
907
+ "url" => "Print the OAuth authorization URL",
908
+ "callback" => "Exchange OAuth code: fb auth callback REDIRECT_URL",
909
+ "status" => "Show current auth state (config, tokens, business)"
910
+ },
911
+ flags: {
912
+ "--client-id" => "OAuth client ID (for setup subcommand)",
913
+ "--client-secret" => "OAuth client secret (for setup subcommand)"
914
+ }
915
+ },
916
+ business: {
917
+ description: "List or select a business",
918
+ usage: "fb business [--select ID]",
919
+ interactive: "Interactive with --select (no value); non-interactive with --select ID",
920
+ flags: {
921
+ "--select ID" => "Set active business by ID",
922
+ "--select" => "Interactive business picker (no value)"
923
+ }
310
924
  },
311
925
  log: {
312
- description: "Log a time entry (interactive prompts with defaults from last use)",
926
+ description: "Log a time entry",
313
927
  usage: "fb log",
314
- interactive: true,
928
+ interactive: "Prompts for missing fields when interactive; requires --duration and --note when non-interactive",
315
929
  flags: {
316
- "--client" => "Pre-select client by name (skip prompt)",
317
- "--project" => "Pre-select project by name (skip prompt)",
318
- "--service" => "Pre-select service by name (skip prompt)",
319
- "--duration" => "Duration in hours (e.g. 2.5)",
320
- "--note" => "Work description",
321
- "--date" => "Date (YYYY-MM-DD, defaults to today)",
930
+ "--client" => "Client name (required non-interactive if multiple clients, auto-selected if single)",
931
+ "--project" => "Project name (optional, auto-selected if single)",
932
+ "--service" => "Service name (optional)",
933
+ "--duration" => "Duration in hours, e.g. 2.5 (required non-interactive)",
934
+ "--note" => "Work description (required non-interactive)",
935
+ "--date" => "Date YYYY-MM-DD (defaults to today)",
322
936
  "--yes" => "Skip confirmation prompt"
323
937
  }
324
938
  },
@@ -329,16 +943,64 @@ module FB
329
943
  "--from" => "Start date (YYYY-MM-DD, open-ended if omitted)",
330
944
  "--to" => "End date (YYYY-MM-DD, open-ended if omitted)",
331
945
  "--month" => "Month (1-12, defaults to current)",
332
- "--year" => "Year (defaults to current)",
333
- "--format" => "Output format: table (default) or json"
946
+ "--year" => "Year (defaults to current)"
334
947
  }
335
948
  },
336
- help: {
337
- description: "Show help information",
338
- usage: "fb help [COMMAND]",
949
+ clients: {
950
+ description: "List all clients",
951
+ usage: "fb clients"
952
+ },
953
+ projects: {
954
+ description: "List all projects",
955
+ usage: "fb projects",
956
+ flags: {
957
+ "--client" => "Filter by client name"
958
+ }
959
+ },
960
+ services: {
961
+ description: "List all services",
962
+ usage: "fb services"
963
+ },
964
+ status: {
965
+ description: "Show hours summary for today, this week, and this month",
966
+ usage: "fb status"
967
+ },
968
+ delete: {
969
+ description: "Delete a time entry",
970
+ usage: "fb delete --id ID --yes",
971
+ interactive: "Interactive picker when no --id; requires --id when non-interactive",
972
+ flags: {
973
+ "--id" => "Time entry ID (required non-interactive)",
974
+ "--yes" => "Skip confirmation prompt"
975
+ }
976
+ },
977
+ edit: {
978
+ description: "Edit a time entry",
979
+ usage: "fb edit --id ID [--duration H] [--note TEXT] --yes",
980
+ interactive: "Interactive picker and field editor when no --id; requires --id when non-interactive",
339
981
  flags: {
340
- "--format" => "Output format: text (default) or json"
982
+ "--id" => "Time entry ID (required non-interactive)",
983
+ "--duration" => "New duration in hours",
984
+ "--note" => "New note",
985
+ "--date" => "New date (YYYY-MM-DD)",
986
+ "--client" => "New client name",
987
+ "--project" => "New project name",
988
+ "--service" => "New service name",
989
+ "--yes" => "Skip confirmation prompt"
990
+ }
991
+ },
992
+ cache: {
993
+ description: "Manage cached data",
994
+ usage: "fb cache [refresh|clear|status]",
995
+ subcommands: {
996
+ "refresh" => "Force-refresh all cached data",
997
+ "clear" => "Delete cache file",
998
+ "status" => "Show cache age and staleness"
341
999
  }
1000
+ },
1001
+ help: {
1002
+ description: "Show help information",
1003
+ usage: "fb help [COMMAND]"
342
1004
  }
343
1005
  }
344
1006
  }
data/lib/fb/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module FB
4
- VERSION = "0.2.0"
4
+ VERSION = "0.3.0"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: freshbooks-cli
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - parasquid