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