flycal-cli 1.0 → 1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Flycal
4
+ # Named and custom slot windows. YAML/JSON shape matches ~/.flycal/config.yml:
5
+ #
6
+ # days: [1, 2, 3, 4, 5]
7
+ # hours:
8
+ # - 9:30-13:00
9
+ # - 14:00-18:30
10
+ module SlotTemplates
11
+ BUILTIN = {
12
+ "work" => {
13
+ "days" => [ 1, 2, 3, 4, 5 ],
14
+ "hours" => [ "9:30-13:00", "14:00-18:30" ]
15
+ },
16
+ "dinner" => {
17
+ "days" => [ 1, 2, 3, 4, 5, 6 ],
18
+ "hours" => [ "19-23" ]
19
+ }
20
+ }.freeze
21
+
22
+ module_function
23
+
24
+ def names(catalog = nil)
25
+ normalize_catalog(catalog || DefaultSlots.templates).keys
26
+ end
27
+
28
+ # Resolve a named template, or a custom YAML-shaped hash.
29
+ # +custom+ wins over +name+ when present.
30
+ def resolve(name: nil, custom: nil, catalog: nil)
31
+ if custom_present?(custom)
32
+ parsed = parse(custom)
33
+ return parsed.merge(name: "custom")
34
+ end
35
+
36
+ source = normalize_catalog(catalog || DefaultSlots.templates)
37
+ raise Error, "slots.templates is missing." if source.empty?
38
+ key = name.to_s.strip
39
+ key = source.keys.first.to_s if key.empty?
40
+ definition = source[key]
41
+ unless definition
42
+ raise Error, "Unknown slots template #{key.inspect}. Available: #{source.keys.join(", ")}"
43
+ end
44
+
45
+ parse(definition).merge(name: key)
46
+ end
47
+
48
+ def parse(definition)
49
+ data = indifferent(definition)
50
+ days = parse_days(data[:days])
51
+ hours = parse_hours(data[:hours])
52
+ { days: days, hours: hours }
53
+ end
54
+
55
+ def parse_days(values)
56
+ days = Array(values).map(&:to_i)
57
+ raise Error, "slots template days cannot be empty." if days.empty?
58
+
59
+ invalid = days.reject { |d| d.between?(1, 7) }
60
+ raise Error, "Invalid template days #{invalid.inspect}. Use 1 (Mon) .. 7 (Sun)." unless invalid.empty?
61
+
62
+ days.uniq
63
+ end
64
+
65
+ def parse_hours(values)
66
+ return values if values.is_a?(Array) && values.first.is_a?(Array)
67
+
68
+ ranges = Array(values).map { |item| parse_hour_range(item) }
69
+ raise Error, "template hours cannot be empty." if ranges.empty?
70
+
71
+ ranges.sort_by { |sh, sm, _eh, _em| (sh * 60) + sm }
72
+ end
73
+
74
+ def parse_hour_range(item)
75
+ start_str, end_str = item.to_s.strip.split("-", 2)
76
+ if start_str.nil? || end_str.nil? || start_str.empty? || end_str.empty?
77
+ raise Error, "Invalid hours item #{item.inspect}. Use format like '9-13' or '14:30-18:00'."
78
+ end
79
+
80
+ sh, sm = parse_clock(start_str)
81
+ eh, em = parse_clock(end_str)
82
+ start_minutes = (sh * 60) + sm
83
+ end_minutes = (eh * 60) + em
84
+ raise Error, "Invalid hours range #{item.inspect}: end must be after start." if end_minutes <= start_minutes
85
+
86
+ [ sh, sm, eh, em ]
87
+ end
88
+
89
+ def parse_clock(value)
90
+ str = value.to_s.strip
91
+ if str.match?(/\A\d{1,2}\z/)
92
+ hour = str.to_i
93
+ raise Error, "Invalid hour #{value.inspect}. Must be 0-23." unless hour.between?(0, 23)
94
+
95
+ return [ hour, 0 ]
96
+ end
97
+
98
+ hour_s, minute_s = str.split(":", 2)
99
+ hour = hour_s.to_i
100
+ minute = minute_s.to_i
101
+ unless hour.between?(0, 23) && minute.between?(0, 59)
102
+ raise Error, "Invalid time #{value.inspect}. Use HH:MM (e.g. 9:00, 18:30)."
103
+ end
104
+
105
+ [ hour, minute ]
106
+ end
107
+
108
+ def custom_present?(custom)
109
+ return false if custom.nil?
110
+ return false if custom.respond_to?(:blank?) && custom.blank?
111
+ return false if custom.respond_to?(:empty?) && custom.empty?
112
+
113
+ true
114
+ end
115
+
116
+ def normalize_catalog(catalog)
117
+ indifferent(catalog).each_with_object({}) do |(key, value), out|
118
+ out[key.to_s] = value
119
+ end
120
+ end
121
+
122
+ def indifferent(value)
123
+ return {} if value.nil?
124
+ return value.with_indifferent_access if value.respond_to?(:with_indifferent_access)
125
+
126
+ value
127
+ end
128
+ end
129
+ end
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+ require "date"
5
+ require "active_support/time"
6
+
7
+ module Flycal
8
+ # Named-zone clock for template hours and every datetime Flycal emits.
9
+ #
10
+ # Wall-clock windows (work 9:30–13, dinner 19–23, template_custom) are local
11
+ # to this zone — never the server process TZ (often UTC in production).
12
+ # JSON start/end/from/to always share that same offset.
13
+ module TimeFormat
14
+ DEFAULT_ZONE = "Europe/Rome"
15
+
16
+ module_function
17
+
18
+ def with_zone(name)
19
+ previous = Thread.current[:flycal_timezone]
20
+ Thread.current[:flycal_timezone] = coerce_zone(name)
21
+ yield current_zone
22
+ ensure
23
+ Thread.current[:flycal_timezone] = previous
24
+ end
25
+
26
+ def current_zone
27
+ Thread.current[:flycal_timezone] || coerce_zone(DEFAULT_ZONE)
28
+ end
29
+
30
+ def coerce_zone(name)
31
+ return name if name.is_a?(ActiveSupport::TimeZone)
32
+
33
+ key = name.to_s.strip
34
+ key = DEFAULT_ZONE if key.empty?
35
+ ActiveSupport::TimeZone[key] || ActiveSupport::TimeZone[DEFAULT_ZONE]
36
+ end
37
+
38
+ def zone_name(name = nil)
39
+ zone = if name.nil? || name.to_s.strip.empty?
40
+ current_zone
41
+ else
42
+ coerce_zone(name)
43
+ end
44
+ zone.tzinfo&.identifier || zone.name
45
+ end
46
+
47
+ # explicit param → Google calendar timeZone → FLYCAL_TIMEZONE → Europe/Rome.
48
+ # Never uses ENV["TZ"] (Docker/Kamal set that to UTC).
49
+ def resolve(explicit: nil, calendars: nil, calendar_ids: nil)
50
+ key = explicit.to_s.strip
51
+ return zone_name(key) unless key.empty?
52
+
53
+ from_cal = from_calendars(calendars, calendar_ids)
54
+ return from_cal if from_cal
55
+
56
+ env = ENV["FLYCAL_TIMEZONE"].to_s.strip
57
+ return zone_name(env) unless env.empty?
58
+
59
+ DEFAULT_ZONE
60
+ end
61
+
62
+ def from_calendars(calendars, calendar_ids = nil)
63
+ return nil if calendars.nil? || (calendars.respond_to?(:empty?) && calendars.empty?)
64
+
65
+ ids = Array(calendar_ids).map(&:to_s)
66
+ list = Array(calendars)
67
+ selected =
68
+ if ids.empty?
69
+ list
70
+ else
71
+ matched = list.select { |cal| cal.respond_to?(:id) && ids.include?(cal.id.to_s) }
72
+ matched.empty? ? list : matched
73
+ end
74
+ cal = selected.find { |c| c.respond_to?(:primary) && c.primary } || selected.first
75
+ tz = cal.respond_to?(:time_zone) ? cal.time_zone : nil
76
+ return nil if tz.to_s.strip.empty?
77
+
78
+ zone_name(tz)
79
+ end
80
+
81
+ def now
82
+ current_zone.now
83
+ end
84
+
85
+ def today
86
+ current_zone.today
87
+ end
88
+
89
+ def local(year, month, day, hour = 0, min = 0, sec = 0, zone: current_zone)
90
+ coerce_zone(zone).local(year, month, day, hour, min, sec)
91
+ end
92
+
93
+ def iso8601(value, zone: current_zone)
94
+ unified(value, zone: zone)&.iso8601
95
+ end
96
+
97
+ def unified(value, zone: current_zone)
98
+ time = coerce(value)
99
+ return nil unless time
100
+
101
+ coerce_zone(zone).at(time.to_f)
102
+ end
103
+
104
+ def coerce(value)
105
+ return nil if value.nil?
106
+ return value if value.is_a?(Time)
107
+ return value.to_time if value.respond_to?(:to_time)
108
+
109
+ Time.parse(value.to_s)
110
+ rescue ArgumentError
111
+ nil
112
+ end
113
+ end
114
+ end
data/lib/flycal.rb CHANGED
@@ -4,7 +4,7 @@
4
4
  # Under Rails, Zeitwerk autoloads lib/flycal/*.
5
5
  # The CLI gem entrypoint (flycal_cli/lib/flycal_cli.rb) eager-requires what it needs.
6
6
  module Flycal
7
- VERSION = "1.0"
7
+ VERSION = "1.2"
8
8
 
9
9
  def self.connect(credentials:)
10
10
  Client.new(credentials: credentials)
data/lib/flycal_cli.rb CHANGED
@@ -10,6 +10,7 @@ $LOAD_PATH.unshift(cli_lib) unless $LOAD_PATH.include?(cli_lib)
10
10
  require "flycal/version"
11
11
  require "flycal/error"
12
12
  require "flycal/locale"
13
+ require "flycal/time_format"
13
14
  require "flycal/credentials"
14
15
  require "flycal/event_mapper"
15
16
  require "flycal/cache_key"
@@ -21,11 +22,15 @@ require "flycal/description_query"
21
22
  require "flycal/calendar_query"
22
23
  require "flycal/calendar_service"
23
24
  require "flycal/slot_finder"
25
+ require "flycal/slot_templates"
26
+ require "flycal/default_slots"
27
+ require "flycal/slot_query"
24
28
  require "flycal/slot_formatter"
25
29
  require "flycal/mock"
26
30
  require "flycal/mock/config"
27
31
  require "flycal/mock/event_generator"
28
32
  require "flycal/mock/calendar_service"
33
+ require "flycal/mock/slot_scenario"
29
34
  require "flycal/pipeline"
30
35
  require "flycal/pipeline/params"
31
36
  require "flycal/pipeline/retriever"
data/locales/en.json CHANGED
@@ -28,6 +28,24 @@
28
28
  "failed": "Error: could not open config with %{editor}."
29
29
  }
30
30
  },
31
+ "login": {
32
+ "prompt": "How do you want to sign in?",
33
+ "options": {
34
+ "flycal": "Google account (Flycal)",
35
+ "json": "Your own Google Cloud client JSON (~/.flycal/credentials.json)"
36
+ },
37
+ "already_connected": "✓ You are already connected to your Google account.",
38
+ "next_config": "Run 'flycal config' to set the default calendar.",
39
+ "success": "✓ Authentication completed successfully!",
40
+ "cancelled": "Login cancelled.",
41
+ "unknown_client": "Unknown OAuth client %{client}. Use flycal or json.",
42
+ "open_link": "Open this link in your browser to authenticate:",
43
+ "waiting": "After authentication, you will be redirected back here automatically.",
44
+ "browser_done_title": "Authentication complete!",
45
+ "browser_done_body": "You can close this window and return to the terminal.",
46
+ "flycal_missing": "Flycal OAuth client is missing from this install (%{path}).",
47
+ "json_missing": "Credentials file not found at %{path}.\nCreate a Desktop app at https://console.cloud.google.com/apis/credentials\nand save the JSON there.\nAuthorized redirect URI: %{redirect}"
48
+ },
31
49
  "slots": {
32
50
  "no_available": "No available slots found.",
33
51
  "no_calendar": "No calendar found. Run 'flycal config' to set a default.",
data/locales/it.json CHANGED
@@ -28,6 +28,24 @@
28
28
  "failed": "Errore: impossibile aprire il config con %{editor}."
29
29
  }
30
30
  },
31
+ "login": {
32
+ "prompt": "Come vuoi accedere?",
33
+ "options": {
34
+ "flycal": "Account Google (Flycal)",
35
+ "json": "Il tuo client JSON di Google Cloud (~/.flycal/credentials.json)"
36
+ },
37
+ "already_connected": "✓ Sei già connesso al tuo account Google.",
38
+ "next_config": "Esegui 'flycal config' per impostare il calendario predefinito.",
39
+ "success": "✓ Autenticazione completata!",
40
+ "cancelled": "Login annullato.",
41
+ "unknown_client": "Client OAuth sconosciuto: %{client}. Usa flycal o json.",
42
+ "open_link": "Apri questo link nel browser per autenticarti:",
43
+ "waiting": "Dopo l'autenticazione verrai reindirizzato qui automaticamente.",
44
+ "browser_done_title": "Autenticazione completata!",
45
+ "browser_done_body": "Puoi chiudere questa finestra e tornare al terminale.",
46
+ "flycal_missing": "Il client OAuth Flycal manca da questa installazione (%{path}).",
47
+ "json_missing": "File credenziali non trovato: %{path}.\nCrea un'app Desktop su https://console.cloud.google.com/apis/credentials\ne salva il JSON lì.\nURI di reindirizzamento autorizzato: %{redirect}"
48
+ },
31
49
  "slots": {
32
50
  "no_available": "Nessuno slot disponibile.",
33
51
  "no_calendar": "Nessun calendario trovato. Esegui 'flycal config' per impostare il predefinito.",
data/mcp/tools.json CHANGED
@@ -82,7 +82,7 @@
82
82
  "path": "~/.flycal/config.yml",
83
83
  "credentials_path": "~/.flycal/credentials.json",
84
84
  "tokens_path": "~/.flycal/tokens.yml",
85
- "credentials_note": "Default/test phase: project-provided API credentials just run flycal login. Creating your own Google Cloud Console OAuth client is optional and only needed if you want to use your own API project; save that JSON as ~/.flycal/credentials.json then run flycal login.",
85
+ "credentials_note": "flycal login offers two clients: (1) Flycal Desktop OAuth, no Google Cloud project needed; (2) optional ~/.flycal/credentials.json from your own Desktop app. Non-interactive default is flycal (`flycal login --client flycal`). For your own JSON: `flycal login --client json`.",
86
86
  "keys": {
87
87
  "calendar_default": {
88
88
  "type": ["string", "null"],
@@ -206,7 +206,7 @@
206
206
  {
207
207
  "name": "flycal_login",
208
208
  "title": "Connect Google account",
209
- "description": "Start OAuth browser login. Requires ~/.flycal/credentials.json and a browser/TTY. Interactive.",
209
+ "description": "Start OAuth browser login. Interactive menu: Flycal Desktop client (default) or ~/.flycal/credentials.json. Use --client flycal|json to skip the menu.",
210
210
  "interactive": true,
211
211
  "requires_auth": false,
212
212
  "side_effects": ["writes_tokens"],
@@ -214,6 +214,13 @@
214
214
  "argv": ["flycal", "login"]
215
215
  },
216
216
  "parameters": {
217
+ "client": {
218
+ "cli": ["--client"],
219
+ "type": "string",
220
+ "enum": ["flycal", "json"],
221
+ "required": false,
222
+ "description": "Skip the login menu. flycal = Flycal Desktop OAuth; json = ~/.flycal/credentials.json."
223
+ },
217
224
  "locale": {
218
225
  "cli": ["--locale"],
219
226
  "type": "string",
@@ -226,6 +233,11 @@
226
233
  "type": "object",
227
234
  "additionalProperties": false,
228
235
  "properties": {
236
+ "client": {
237
+ "type": "string",
238
+ "enum": ["flycal", "json"],
239
+ "description": "OAuth client (--client flycal|json)."
240
+ },
229
241
  "locale": {
230
242
  "type": "string",
231
243
  "enum": ["en", "it"],
@@ -595,7 +607,7 @@
595
607
  "execution": "Spawn the flycal binary with argv from command.argv, or build argv from argv_template + option_map for each provided parameter. Capture stdout/stderr and exit code. Return stdout as MCP text content.",
596
608
  "parameter_building": "For each non-null input property present in option_map, append the mapped flag and value. Quote values with spaces. Always append --locale when locale is set.",
597
609
  "interactive_tools": "Do not auto-run tools with interactive=true unless the host provides a TTY. Tell the user to run them in a terminal.",
598
- "auth_errors": "If output indicates the user is not connected, suggest flycal login (after credentials.json is in place).",
610
+ "auth_errors": "If output indicates the user is not connected, suggest flycal login (Flycal Desktop client by default; credentials.json only if they use their own Google Cloud project).",
599
611
  "tool_filter": "Prefer exposing flycal_search, flycal_slots, flycal_calendars, and flycal_version to agents."
600
612
  }
601
613
  }
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: flycal-cli
3
3
  version: !ruby/object:Gem::Version
4
- version: '1.0'
4
+ version: '1.2'
5
5
  platform: ruby
6
6
  authors:
7
7
  - flycal-cli
@@ -134,6 +134,7 @@ files:
134
134
  - README.md
135
135
  - bin/flycal
136
136
  - config/defaults.yml
137
+ - config/oauth_client.json
137
138
  - lib/flycal.rb
138
139
  - lib/flycal/cache_annotator.rb
139
140
  - lib/flycal/cache_key.rb
@@ -149,6 +150,7 @@ files:
149
150
  - lib/flycal/client.rb
150
151
  - lib/flycal/credentials.rb
151
152
  - lib/flycal/date_time_parser.rb
153
+ - lib/flycal/default_slots.rb
152
154
  - lib/flycal/description_query.rb
153
155
  - lib/flycal/duration_parser.rb
154
156
  - lib/flycal/error.rb
@@ -158,6 +160,7 @@ files:
158
160
  - lib/flycal/mock/calendar_service.rb
159
161
  - lib/flycal/mock/config.rb
160
162
  - lib/flycal/mock/event_generator.rb
163
+ - lib/flycal/mock/slot_scenario.rb
161
164
  - lib/flycal/pipeline.rb
162
165
  - lib/flycal/pipeline/aggregator.rb
163
166
  - lib/flycal/pipeline/json_renderer.rb
@@ -168,6 +171,9 @@ files:
168
171
  - lib/flycal/pipeline/text_renderer.rb
169
172
  - lib/flycal/slot_finder.rb
170
173
  - lib/flycal/slot_formatter.rb
174
+ - lib/flycal/slot_query.rb
175
+ - lib/flycal/slot_templates.rb
176
+ - lib/flycal/time_format.rb
171
177
  - lib/flycal/version.rb
172
178
  - lib/flycal_cli.rb
173
179
  - locales/en.json