freshbooks-cli 0.2.1 → 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: 25953e6d4250452b12f5259b0c1a0ef9e8b01e58f23e44d5ab61784af3376ffd
4
- data.tar.gz: 39158a737b7709ce7533c2e10882a0b07b3c54748aa4a024ceb8c27634751833
3
+ metadata.gz: 7d8d73d99463668eb02e32550fe31e0c0ee32f616bd8fbfd9839df01f5a22052
4
+ data.tar.gz: ce0758e59c4e5631b8d84c87d8c56bdb63e87386021e27de32922e162d0dad55
5
5
  SHA512:
6
- metadata.gz: 422b8d06f3058c3f33fb42c0651010c66fad65b8c95ad164a717dbf943616e9604233844ab5b315e368dfa3f8df8fbb5b3d4458ae52b284154e38badf185c684
7
- data.tar.gz: 8647a569513bf939ae0df062dfd60901e623df0accfd4e4908a509e8c9feefc7241a9e30a451b2875739d7b2288f231e879e5129ae5e37f312d49d8f64ffe28d
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,6 +10,9 @@ 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
+
13
16
  # --- version ---
14
17
 
15
18
  desc "version", "Print the current version"
@@ -19,12 +22,153 @@ module FB
19
22
 
20
23
  # --- auth ---
21
24
 
22
- desc "auth", "Authenticate with FreshBooks via OAuth2"
23
- def auth
24
- config = Auth.require_config
25
- tokens = Auth.authorize(config)
26
- Auth.discover_business(tokens["access_token"], config)
27
- 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
28
172
  end
29
173
 
30
174
  # --- log ---
@@ -40,25 +184,26 @@ module FB
40
184
  def log
41
185
  Auth.valid_access_token
42
186
  defaults = Auth.load_defaults
43
- interactive = !(options[:client] && options[:duration] && options[:note])
44
187
 
45
- client = select_client(defaults, interactive)
46
- project = select_project(client["id"], defaults, interactive)
47
- service = select_service(defaults, interactive)
48
- date = pick_date(interactive)
49
- duration_hours = pick_duration(interactive)
50
- 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
51
194
 
52
195
  client_name = display_name(client)
53
196
 
54
- puts "\n--- Time Entry Summary ---"
55
- puts " Client: #{client_name}"
56
- puts " Project: #{project ? project["title"] : "(none)"}"
57
- puts " Service: #{service ? service["name"] : "(none)"}"
58
- puts " Date: #{date}"
59
- puts " Duration: #{duration_hours}h"
60
- puts " Note: #{note}"
61
- 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
62
207
 
63
208
  unless options[:yes]
64
209
  print "Submit? (Y/n): "
@@ -70,14 +215,19 @@ module FB
70
215
  "is_logged" => true,
71
216
  "duration" => (duration_hours * 3600).to_i,
72
217
  "note" => note,
73
- "started_at" => date,
218
+ "started_at" => normalize_datetime(date),
74
219
  "client_id" => client["id"]
75
220
  }
76
221
  entry["project_id"] = project["id"] if project
77
222
  entry["service_id"] = service["id"] if service
78
223
 
79
- Api.create_time_entry(entry)
80
- 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
81
231
 
82
232
  new_defaults = { "client_id" => client["id"] }
83
233
  new_defaults["project_id"] = project["id"] if project
@@ -92,7 +242,6 @@ module FB
92
242
  method_option :year, type: :numeric, desc: "Year"
93
243
  method_option :from, type: :string, desc: "Start date (YYYY-MM-DD)"
94
244
  method_option :to, type: :string, desc: "End date (YYYY-MM-DD)"
95
- method_option :format, type: :string, default: "table", desc: "Output format: table or json"
96
245
  def entries
97
246
  Auth.valid_access_token
98
247
 
@@ -124,7 +273,11 @@ module FB
124
273
  end
125
274
 
126
275
  if entries.empty?
127
- puts "No time entries#{label ? " #{label}" : ""}."
276
+ if options[:format] == "json"
277
+ puts "[]"
278
+ else
279
+ puts "No time entries#{label ? " #{label}" : ""}."
280
+ end
128
281
  return
129
282
  end
130
283
 
@@ -140,29 +293,287 @@ module FB
140
293
  date = e["started_at"] || "?"
141
294
  client = maps[:clients][e["client_id"].to_s] || e["client_id"].to_s
142
295
  project = maps[:projects][e["project_id"].to_s] || "-"
296
+ service = maps[:services][e["service_id"].to_s] || "-"
143
297
  note = (e["note"] || "").slice(0, 50)
144
298
  hours = (e["duration"].to_i / 3600.0).round(2)
145
- [date, client, project, note, "#{hours}h"]
299
+ [e["id"].to_s, date, client, project, service, note, "#{hours}h"]
146
300
  end
147
301
 
148
- headers = ["Date", "Client", "Project", "Note", "Duration"]
149
- widths = headers.each_with_index.map do |h, i|
150
- [h.length, *rows.map { |r| r[i].to_s.length }].max
151
- end
152
-
153
- fmt = widths.map { |w| "%-#{w}s" }.join(" ")
154
- puts fmt % headers
155
- puts widths.map { |w| "-" * w }.join(" ")
156
- rows.each { |r| puts fmt % r }
302
+ print_table(["ID", "Date", "Client", "Project", "Service", "Note", "Duration"], rows)
157
303
 
158
304
  total = entries.sum { |e| e["duration"].to_i } / 3600.0
159
305
  puts "\nTotal: #{total.round(2)}h"
160
306
  end
161
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
+
162
574
  # --- help ---
163
575
 
164
576
  desc "help [COMMAND]", "Describe available commands or one specific command"
165
- method_option :format, type: :string, desc: "Output format: text (default) or json"
166
577
  def help(command = nil)
167
578
  if options[:format] == "json"
168
579
  puts JSON.pretty_generate(help_json)
@@ -173,7 +584,12 @@ module FB
173
584
 
174
585
  private
175
586
 
176
- 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)
177
593
  clients = Spinner.spin("Fetching clients") { Api.fetch_clients }
178
594
 
179
595
  if options[:client]
@@ -184,6 +600,15 @@ module FB
184
600
 
185
601
  abort("No clients found.") if clients.empty?
186
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
+
187
612
  puts "\nClients:\n\n"
188
613
  clients.each_with_index do |c, i|
189
614
  name = display_name(c)
@@ -206,7 +631,7 @@ module FB
206
631
  clients[idx]
207
632
  end
208
633
 
209
- def select_project(client_id, defaults, interactive)
634
+ def select_project(client_id, defaults)
210
635
  projects = Spinner.spin("Fetching projects") { Api.fetch_projects_for_client(client_id) }
211
636
 
212
637
  if options[:project]
@@ -217,6 +642,14 @@ module FB
217
642
 
218
643
  return nil if projects.empty?
219
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
+
220
653
  puts "\nProjects:\n\n"
221
654
  projects.each_with_index do |p, i|
222
655
  default_marker = p["id"].to_i == defaults["project_id"].to_i ? " [default]" : ""
@@ -237,17 +670,28 @@ module FB
237
670
  projects[idx]
238
671
  end
239
672
 
240
- 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
+
241
681
  if options[:service]
242
- services = Spinner.spin("Fetching services") { Api.fetch_services }
243
682
  match = services.find { |s| s["name"].downcase == options[:service].downcase }
244
683
  abort("Service not found: #{options[:service]}") unless match
245
684
  return match
246
685
  end
247
686
 
248
- 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
249
694
 
250
- services = Spinner.spin("Fetching services") { Api.fetch_services }
251
695
  return nil if services.empty?
252
696
 
253
697
  puts "\nServices:\n\n"
@@ -270,35 +714,175 @@ module FB
270
714
  services[idx]
271
715
  end
272
716
 
273
- def pick_date(interactive)
717
+ def pick_date
274
718
  return options[:date] if options[:date]
275
719
 
276
720
  today = Date.today.to_s
277
- return today unless interactive
721
+ return today unless interactive?
278
722
 
279
723
  print "\nDate [#{today}]: "
280
724
  input = $stdin.gets&.strip
281
725
  (input.nil? || input.empty?) ? today : input
282
726
  end
283
727
 
284
- def pick_duration(interactive)
728
+ def pick_duration
285
729
  return options[:duration] if options[:duration]
286
730
 
731
+ abort("Missing required flag: --duration") unless interactive?
732
+
287
733
  print "\nDuration (hours): "
288
734
  input = $stdin.gets&.strip
289
735
  abort("Duration is required.") if input.nil? || input.empty?
290
736
  input.to_f
291
737
  end
292
738
 
293
- def pick_note(interactive)
739
+ def pick_note
294
740
  return options[:note] if options[:note]
295
741
 
742
+ abort("Missing required flag: --note") unless interactive?
743
+
296
744
  print "\nNote: "
297
745
  input = $stdin.gets&.strip
298
746
  abort("Note is required.") if input.nil? || input.empty?
299
747
  input
300
748
  end
301
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
+
302
886
  def display_name(client)
303
887
  name = client["organization"]
304
888
  (name.nil? || name.empty?) ? "#{client["fname"]} #{client["lname"]}" : name
@@ -309,23 +893,46 @@ module FB
309
893
  name: "fb",
310
894
  description: "FreshBooks time tracking CLI",
311
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
+ },
312
900
  commands: {
313
901
  auth: {
314
902
  description: "Authenticate with FreshBooks via OAuth2",
315
- usage: "fb auth",
316
- 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
+ }
317
924
  },
318
925
  log: {
319
- description: "Log a time entry (interactive prompts with defaults from last use)",
926
+ description: "Log a time entry",
320
927
  usage: "fb log",
321
- interactive: true,
928
+ interactive: "Prompts for missing fields when interactive; requires --duration and --note when non-interactive",
322
929
  flags: {
323
- "--client" => "Pre-select client by name (skip prompt)",
324
- "--project" => "Pre-select project by name (skip prompt)",
325
- "--service" => "Pre-select service by name (skip prompt)",
326
- "--duration" => "Duration in hours (e.g. 2.5)",
327
- "--note" => "Work description",
328
- "--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)",
329
936
  "--yes" => "Skip confirmation prompt"
330
937
  }
331
938
  },
@@ -336,16 +943,64 @@ module FB
336
943
  "--from" => "Start date (YYYY-MM-DD, open-ended if omitted)",
337
944
  "--to" => "End date (YYYY-MM-DD, open-ended if omitted)",
338
945
  "--month" => "Month (1-12, defaults to current)",
339
- "--year" => "Year (defaults to current)",
340
- "--format" => "Output format: table (default) or json"
946
+ "--year" => "Year (defaults to current)"
341
947
  }
342
948
  },
343
- help: {
344
- description: "Show help information",
345
- 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",
346
981
  flags: {
347
- "--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"
348
999
  }
1000
+ },
1001
+ help: {
1002
+ description: "Show help information",
1003
+ usage: "fb help [COMMAND]"
349
1004
  }
350
1005
  }
351
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.1"
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.1
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - parasquid