mcpeasy 0.1.0 → 0.2.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.
@@ -0,0 +1,293 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/setup"
4
+ require "google/apis/drive_v3"
5
+ require "googleauth"
6
+ require "signet/oauth_2/client"
7
+ require "fileutils"
8
+ require "json"
9
+ require "time"
10
+ require_relative "../_google/auth_server"
11
+ require_relative "../../mcpeasy/config"
12
+
13
+ module Gdrive
14
+ class Service
15
+ SCOPES = [
16
+ "https://www.googleapis.com/auth/calendar.readonly",
17
+ "https://www.googleapis.com/auth/drive.readonly"
18
+ ]
19
+ SCOPE = SCOPES.join(" ")
20
+
21
+ # MIME type mappings for Google Workspace documents
22
+ EXPORT_FORMATS = {
23
+ "application/vnd.google-apps.document" => {
24
+ format: "text/markdown",
25
+ extension: ".md"
26
+ },
27
+ "application/vnd.google-apps.spreadsheet" => {
28
+ format: "text/csv",
29
+ extension: ".csv"
30
+ },
31
+ "application/vnd.google-apps.presentation" => {
32
+ format: "text/plain",
33
+ extension: ".txt"
34
+ },
35
+ "application/vnd.google-apps.drawing" => {
36
+ format: "image/png",
37
+ extension: ".png"
38
+ }
39
+ }.freeze
40
+
41
+ def initialize(skip_auth: false)
42
+ ensure_env!
43
+ @service = Google::Apis::DriveV3::DriveService.new
44
+ @service.authorization = authorize unless skip_auth
45
+ end
46
+
47
+ def search_files(query, max_results: 10)
48
+ results = @service.list_files(
49
+ q: "fullText contains '#{query.gsub("'", "\\'")}' and trashed=false",
50
+ page_size: max_results,
51
+ fields: "files(id,name,mimeType,size,modifiedTime,webViewLink)"
52
+ )
53
+
54
+ files = results.files.map do |file|
55
+ {
56
+ id: file.id,
57
+ name: file.name,
58
+ mime_type: file.mime_type,
59
+ size: file.size&.to_i,
60
+ modified_time: file.modified_time,
61
+ web_view_link: file.web_view_link
62
+ }
63
+ end
64
+
65
+ {files: files, count: files.length}
66
+ rescue Google::Apis::Error => e
67
+ raise "Google Drive API Error: #{e.message}"
68
+ rescue => e
69
+ log_error("search_files", e)
70
+ raise e
71
+ end
72
+
73
+ def get_file_content(file_id)
74
+ # First get file metadata
75
+ file = @service.get_file(file_id, fields: "id,name,mimeType,size")
76
+
77
+ content = if EXPORT_FORMATS.key?(file.mime_type)
78
+ # Export Google Workspace document
79
+ export_format = EXPORT_FORMATS[file.mime_type][:format]
80
+ @service.export_file(file_id, export_format)
81
+ else
82
+ # Download regular file
83
+ @service.get_file(file_id, download_dest: StringIO.new)
84
+ end
85
+
86
+ {
87
+ id: file.id,
88
+ name: file.name,
89
+ mime_type: file.mime_type,
90
+ size: file.size&.to_i,
91
+ content: content.is_a?(StringIO) ? content.string : content
92
+ }
93
+ rescue Google::Apis::Error => e
94
+ raise "Google Drive API Error: #{e.message}"
95
+ rescue => e
96
+ log_error("get_file_content", e)
97
+ raise e
98
+ end
99
+
100
+ def list_files(max_results: 20)
101
+ results = @service.list_files(
102
+ q: "trashed=false",
103
+ page_size: max_results,
104
+ order_by: "modifiedTime desc",
105
+ fields: "files(id,name,mimeType,size,modifiedTime,webViewLink)"
106
+ )
107
+
108
+ files = results.files.map do |file|
109
+ {
110
+ id: file.id,
111
+ name: file.name,
112
+ mime_type: file.mime_type,
113
+ size: file.size&.to_i,
114
+ modified_time: file.modified_time,
115
+ web_view_link: file.web_view_link
116
+ }
117
+ end
118
+
119
+ {files: files, count: files.length}
120
+ rescue Google::Apis::Error => e
121
+ raise "Google Drive API Error: #{e.message}"
122
+ rescue => e
123
+ log_error("list_files", e)
124
+ raise e
125
+ end
126
+
127
+ def test_connection
128
+ about = @service.get_about(fields: "user,storageQuota")
129
+ {
130
+ ok: true,
131
+ user: about.user.display_name,
132
+ email: about.user.email_address,
133
+ storage_used: about.storage_quota&.usage,
134
+ storage_limit: about.storage_quota&.limit
135
+ }
136
+ rescue Google::Apis::Error => e
137
+ raise "Google Drive API Error: #{e.message}"
138
+ rescue => e
139
+ log_error("test_connection", e)
140
+ raise e
141
+ end
142
+
143
+ def authenticate
144
+ perform_auth_flow
145
+ {success: true}
146
+ rescue => e
147
+ {success: false, error: e.message}
148
+ end
149
+
150
+ def perform_auth_flow
151
+ client_id = Mcpeasy::Config.google_client_id
152
+ client_secret = Mcpeasy::Config.google_client_secret
153
+
154
+ unless client_id && client_secret
155
+ raise "Google credentials not found. Please save your credentials.json file using: mcpz config set_google_credentials <path_to_credentials.json>"
156
+ end
157
+
158
+ # Create credentials using OAuth2 flow with localhost redirect
159
+ redirect_uri = "http://localhost:8080"
160
+ client = Signet::OAuth2::Client.new(
161
+ client_id: client_id,
162
+ client_secret: client_secret,
163
+ scope: SCOPE,
164
+ redirect_uri: redirect_uri,
165
+ authorization_uri: "https://accounts.google.com/o/oauth2/auth",
166
+ token_credential_uri: "https://oauth2.googleapis.com/token"
167
+ )
168
+
169
+ # Generate authorization URL
170
+ url = client.authorization_uri.to_s
171
+
172
+ puts "DEBUG: Client ID: #{client_id[0..20]}..."
173
+ puts "DEBUG: Scope: #{SCOPE}"
174
+ puts "DEBUG: Redirect URI: #{redirect_uri}"
175
+ puts
176
+
177
+ # Start callback server to capture OAuth code
178
+ puts "Starting temporary web server to capture OAuth callback..."
179
+ puts "Opening authorization URL in your default browser..."
180
+ puts url
181
+ puts
182
+
183
+ # Automatically open URL in default browser on macOS/Unix
184
+ if system("which open > /dev/null 2>&1")
185
+ system("open", url)
186
+ else
187
+ puts "Could not automatically open browser. Please copy the URL above manually."
188
+ end
189
+ puts
190
+ puts "Waiting for OAuth callback... (will timeout in 60 seconds)"
191
+
192
+ # Wait for the authorization code with timeout
193
+ code = GoogleAuthServer.capture_auth_code
194
+
195
+ unless code
196
+ raise "Failed to receive authorization code. Please try again."
197
+ end
198
+
199
+ puts "✅ Authorization code received!"
200
+ client.code = code
201
+ client.fetch_access_token!
202
+
203
+ # Save credentials to config
204
+ credentials_data = {
205
+ client_id: client.client_id,
206
+ client_secret: client.client_secret,
207
+ scope: client.scope,
208
+ refresh_token: client.refresh_token,
209
+ access_token: client.access_token,
210
+ expires_at: client.expires_at
211
+ }
212
+
213
+ Mcpeasy::Config.save_google_token(credentials_data)
214
+ puts "✅ Authentication successful! Token saved to config"
215
+
216
+ client
217
+ rescue => e
218
+ log_error("perform_auth_flow", e)
219
+ raise "Authentication flow failed: #{e.message}"
220
+ end
221
+
222
+ private
223
+
224
+ def authorize
225
+ credentials_data = Mcpeasy::Config.google_token
226
+ unless credentials_data
227
+ raise <<~ERROR
228
+ Google Drive authentication required!
229
+ Run the auth command first:
230
+ mcpz gdrive auth
231
+ ERROR
232
+ end
233
+
234
+ client = Signet::OAuth2::Client.new(
235
+ client_id: credentials_data.client_id,
236
+ client_secret: credentials_data.client_secret,
237
+ scope: credentials_data.scope.respond_to?(:to_a) ? credentials_data.scope.to_a.join(" ") : credentials_data.scope.to_s,
238
+ refresh_token: credentials_data.refresh_token,
239
+ access_token: credentials_data.access_token,
240
+ token_credential_uri: "https://oauth2.googleapis.com/token"
241
+ )
242
+
243
+ # Check if token needs refresh
244
+ if credentials_data.expires_at
245
+ expires_at = if credentials_data.expires_at.is_a?(String)
246
+ Time.parse(credentials_data.expires_at)
247
+ else
248
+ Time.at(credentials_data.expires_at)
249
+ end
250
+
251
+ if Time.now >= expires_at
252
+ client.refresh!
253
+ # Update saved credentials with new access token
254
+ updated_data = {
255
+ client_id: credentials_data.client_id,
256
+ client_secret: credentials_data.client_secret,
257
+ scope: credentials_data.scope.respond_to?(:to_a) ? credentials_data.scope.to_a.join(" ") : credentials_data.scope.to_s,
258
+ refresh_token: credentials_data.refresh_token,
259
+ access_token: client.access_token,
260
+ expires_at: client.expires_at
261
+ }
262
+ Mcpeasy::Config.save_google_token(updated_data)
263
+ end
264
+ end
265
+
266
+ client
267
+ rescue JSON::ParserError
268
+ raise "Invalid token data. Please re-run: mcpz gdrive auth"
269
+ rescue => e
270
+ log_error("authorize", e)
271
+ raise "Authentication failed: #{e.message}"
272
+ end
273
+
274
+ def ensure_env!
275
+ unless Mcpeasy::Config.google_client_id && Mcpeasy::Config.google_client_secret
276
+ raise <<~ERROR
277
+ Google API credentials not configured!
278
+ Please save your Google credentials.json file using:
279
+ mcpz config set_google_credentials <path_to_credentials.json>
280
+ ERROR
281
+ end
282
+ end
283
+
284
+ def log_error(method, error)
285
+ Mcpeasy::Config.ensure_config_dirs
286
+ File.write(
287
+ Mcpeasy::Config.log_file_path("gdrive", "error"),
288
+ "#{Time.now}: #{method} error: #{error.class}: #{error.message}\n#{error.backtrace.join("\n")}\n",
289
+ mode: "a"
290
+ )
291
+ end
292
+ end
293
+ end
@@ -2,156 +2,158 @@
2
2
 
3
3
  require "bundler/setup"
4
4
  require "thor"
5
- require_relative "gmeet_tool"
5
+ require_relative "service"
6
6
 
7
- class GmeetCLI < Thor
8
- desc "test", "Test the Google Calendar API connection"
9
- def test
10
- response = tool.test_connection
7
+ module Gmeet
8
+ class CLI < Thor
9
+ desc "test", "Test the Google Calendar API connection"
10
+ def test
11
+ response = tool.test_connection
11
12
 
12
- if response[:ok]
13
- puts "✅ Successfully connected to Google Calendar"
14
- puts " User: #{response[:user]} (#{response[:email]})"
15
- else
16
- warn "❌ Connection test failed"
13
+ if response[:ok]
14
+ puts "✅ Successfully connected to Google Calendar"
15
+ puts " User: #{response[:user]} (#{response[:email]})"
16
+ else
17
+ warn "❌ Connection test failed"
18
+ end
19
+ rescue RuntimeError => e
20
+ puts "❌ Failed to connect to Google Calendar: #{e.message}"
21
+ exit 1
17
22
  end
18
- rescue RuntimeError => e
19
- puts "❌ Failed to connect to Google Calendar: #{e.message}"
20
- exit 1
21
- end
22
23
 
23
- desc "meetings", "List Google Meet meetings"
24
- method_option :start_date, type: :string, aliases: "-s", desc: "Start date (YYYY-MM-DD)"
25
- method_option :end_date, type: :string, aliases: "-e", desc: "End date (YYYY-MM-DD)"
26
- method_option :max_results, type: :numeric, default: 20, aliases: "-n", desc: "Max number of meetings"
27
- method_option :calendar_id, type: :string, aliases: "-c", desc: "Calendar ID (default: primary)"
28
- def meetings
29
- result = tool.list_meetings(
30
- start_date: options[:start_date],
31
- end_date: options[:end_date],
32
- max_results: options[:max_results],
33
- calendar_id: options[:calendar_id] || "primary"
34
- )
35
- meetings = result[:meetings]
24
+ desc "meetings", "List Google Meet meetings"
25
+ method_option :start_date, type: :string, aliases: "-s", desc: "Start date (YYYY-MM-DD)"
26
+ method_option :end_date, type: :string, aliases: "-e", desc: "End date (YYYY-MM-DD)"
27
+ method_option :max_results, type: :numeric, default: 20, aliases: "-n", desc: "Max number of meetings"
28
+ method_option :calendar_id, type: :string, aliases: "-c", desc: "Calendar ID (default: primary)"
29
+ def meetings
30
+ result = tool.list_meetings(
31
+ start_date: options[:start_date],
32
+ end_date: options[:end_date],
33
+ max_results: options[:max_results],
34
+ calendar_id: options[:calendar_id] || "primary"
35
+ )
36
+ meetings = result[:meetings]
36
37
 
37
- if meetings.empty?
38
- puts "🎥 No Google Meet meetings found for the specified date range"
39
- else
40
- puts "🎥 Found #{result[:count]} Google Meet meeting(s):"
41
- meetings.each_with_index do |meeting, index|
42
- puts " #{index + 1}. #{meeting[:summary] || "No title"}"
43
- puts " Start: #{format_datetime(meeting[:start])}"
44
- puts " End: #{format_datetime(meeting[:end])}"
45
- puts " Description: #{meeting[:description]}" if meeting[:description]
46
- puts " Location: #{meeting[:location]}" if meeting[:location]
47
- puts " Attendees: #{meeting[:attendees].join(", ")}" if meeting[:attendees]&.any?
48
- puts " Meet Link: #{meeting[:meet_link]}"
49
- puts " Calendar Link: #{meeting[:html_link]}"
50
- puts
38
+ if meetings.empty?
39
+ puts "🎥 No Google Meet meetings found for the specified date range"
40
+ else
41
+ puts "🎥 Found #{result[:count]} Google Meet meeting(s):"
42
+ meetings.each_with_index do |meeting, index|
43
+ puts " #{index + 1}. #{meeting[:summary] || "No title"}"
44
+ puts " Start: #{format_datetime(meeting[:start])}"
45
+ puts " End: #{format_datetime(meeting[:end])}"
46
+ puts " Description: #{meeting[:description]}" if meeting[:description]
47
+ puts " Location: #{meeting[:location]}" if meeting[:location]
48
+ puts " Attendees: #{meeting[:attendees].join(", ")}" if meeting[:attendees]&.any?
49
+ puts " Meet Link: #{meeting[:meet_link]}"
50
+ puts " Calendar Link: #{meeting[:html_link]}"
51
+ puts
52
+ end
51
53
  end
54
+ rescue RuntimeError => e
55
+ warn "❌ Failed to list meetings: #{e.message}"
56
+ exit 1
52
57
  end
53
- rescue RuntimeError => e
54
- warn "❌ Failed to list meetings: #{e.message}"
55
- exit 1
56
- end
57
58
 
58
- desc "upcoming", "List upcoming Google Meet meetings"
59
- method_option :max_results, type: :numeric, default: 10, aliases: "-n", desc: "Max number of meetings"
60
- method_option :calendar_id, type: :string, aliases: "-c", desc: "Calendar ID (default: primary)"
61
- def upcoming
62
- result = tool.upcoming_meetings(
63
- max_results: options[:max_results],
64
- calendar_id: options[:calendar_id] || "primary"
65
- )
66
- meetings = result[:meetings]
59
+ desc "upcoming", "List upcoming Google Meet meetings"
60
+ method_option :max_results, type: :numeric, default: 10, aliases: "-n", desc: "Max number of meetings"
61
+ method_option :calendar_id, type: :string, aliases: "-c", desc: "Calendar ID (default: primary)"
62
+ def upcoming
63
+ result = tool.upcoming_meetings(
64
+ max_results: options[:max_results],
65
+ calendar_id: options[:calendar_id] || "primary"
66
+ )
67
+ meetings = result[:meetings]
67
68
 
68
- if meetings.empty?
69
- puts "🎥 No upcoming Google Meet meetings found in the next 24 hours"
70
- else
71
- puts "🎥 Found #{result[:count]} upcoming Google Meet meeting(s):"
72
- meetings.each_with_index do |meeting, index|
73
- puts " #{index + 1}. #{meeting[:summary] || "No title"}"
74
- puts " Start: #{format_datetime(meeting[:start])} (#{meeting[:time_until_start]})"
75
- puts " End: #{format_datetime(meeting[:end])}"
76
- puts " Description: #{meeting[:description]}" if meeting[:description]
77
- puts " Location: #{meeting[:location]}" if meeting[:location]
78
- puts " Attendees: #{meeting[:attendees].join(", ")}" if meeting[:attendees]&.any?
79
- puts " Meet Link: #{meeting[:meet_link]}"
80
- puts " Calendar Link: #{meeting[:html_link]}"
81
- puts
69
+ if meetings.empty?
70
+ puts "🎥 No upcoming Google Meet meetings found in the next 24 hours"
71
+ else
72
+ puts "🎥 Found #{result[:count]} upcoming Google Meet meeting(s):"
73
+ meetings.each_with_index do |meeting, index|
74
+ puts " #{index + 1}. #{meeting[:summary] || "No title"}"
75
+ puts " Start: #{format_datetime(meeting[:start])} (#{meeting[:time_until_start]})"
76
+ puts " End: #{format_datetime(meeting[:end])}"
77
+ puts " Description: #{meeting[:description]}" if meeting[:description]
78
+ puts " Location: #{meeting[:location]}" if meeting[:location]
79
+ puts " Attendees: #{meeting[:attendees].join(", ")}" if meeting[:attendees]&.any?
80
+ puts " Meet Link: #{meeting[:meet_link]}"
81
+ puts " Calendar Link: #{meeting[:html_link]}"
82
+ puts
83
+ end
82
84
  end
85
+ rescue RuntimeError => e
86
+ warn "❌ Failed to list upcoming meetings: #{e.message}"
87
+ exit 1
83
88
  end
84
- rescue RuntimeError => e
85
- warn "❌ Failed to list upcoming meetings: #{e.message}"
86
- exit 1
87
- end
88
89
 
89
- desc "search QUERY", "Search for Google Meet meetings by text content"
90
- method_option :start_date, type: :string, aliases: "-s", desc: "Start date (YYYY-MM-DD)"
91
- method_option :end_date, type: :string, aliases: "-e", desc: "End date (YYYY-MM-DD)"
92
- method_option :max_results, type: :numeric, default: 10, aliases: "-n", desc: "Max number of meetings"
93
- def search(query)
94
- result = tool.search_meetings(
95
- query,
96
- start_date: options[:start_date],
97
- end_date: options[:end_date],
98
- max_results: options[:max_results]
99
- )
100
- meetings = result[:meetings]
90
+ desc "search QUERY", "Search for Google Meet meetings by text content"
91
+ method_option :start_date, type: :string, aliases: "-s", desc: "Start date (YYYY-MM-DD)"
92
+ method_option :end_date, type: :string, aliases: "-e", desc: "End date (YYYY-MM-DD)"
93
+ method_option :max_results, type: :numeric, default: 10, aliases: "-n", desc: "Max number of meetings"
94
+ def search(query)
95
+ result = tool.search_meetings(
96
+ query,
97
+ start_date: options[:start_date],
98
+ end_date: options[:end_date],
99
+ max_results: options[:max_results]
100
+ )
101
+ meetings = result[:meetings]
101
102
 
102
- if meetings.empty?
103
- puts "🔍 No Google Meet meetings found matching '#{query}'"
104
- else
105
- puts "🔍 Found #{result[:count]} Google Meet meeting(s) matching '#{query}':"
106
- meetings.each_with_index do |meeting, index|
107
- puts " #{index + 1}. #{meeting[:summary] || "No title"}"
108
- puts " Start: #{format_datetime(meeting[:start])}"
109
- puts " End: #{format_datetime(meeting[:end])}"
110
- puts " Description: #{meeting[:description]}" if meeting[:description]
111
- puts " Location: #{meeting[:location]}" if meeting[:location]
112
- puts " Meet Link: #{meeting[:meet_link]}"
113
- puts " Calendar Link: #{meeting[:html_link]}"
114
- puts
103
+ if meetings.empty?
104
+ puts "🔍 No Google Meet meetings found matching '#{query}'"
105
+ else
106
+ puts "🔍 Found #{result[:count]} Google Meet meeting(s) matching '#{query}':"
107
+ meetings.each_with_index do |meeting, index|
108
+ puts " #{index + 1}. #{meeting[:summary] || "No title"}"
109
+ puts " Start: #{format_datetime(meeting[:start])}"
110
+ puts " End: #{format_datetime(meeting[:end])}"
111
+ puts " Description: #{meeting[:description]}" if meeting[:description]
112
+ puts " Location: #{meeting[:location]}" if meeting[:location]
113
+ puts " Meet Link: #{meeting[:meet_link]}"
114
+ puts " Calendar Link: #{meeting[:html_link]}"
115
+ puts
116
+ end
115
117
  end
118
+ rescue RuntimeError => e
119
+ warn "❌ Failed to search meetings: #{e.message}"
120
+ exit 1
116
121
  end
117
- rescue RuntimeError => e
118
- warn "❌ Failed to search meetings: #{e.message}"
119
- exit 1
120
- end
121
122
 
122
- desc "url EVENT_ID", "Get the Google Meet URL for a specific event"
123
- method_option :calendar_id, type: :string, aliases: "-c", desc: "Calendar ID (default: primary)"
124
- def url(event_id)
125
- result = tool.get_meeting_url(event_id, calendar_id: options[:calendar_id] || "primary")
123
+ desc "url EVENT_ID", "Get the Google Meet URL for a specific event"
124
+ method_option :calendar_id, type: :string, aliases: "-c", desc: "Calendar ID (default: primary)"
125
+ def url(event_id)
126
+ result = tool.get_meeting_url(event_id, calendar_id: options[:calendar_id] || "primary")
126
127
 
127
- puts "🎥 #{result[:summary] || "Meeting"}"
128
- puts " Start: #{format_datetime(result[:start])}"
129
- puts " End: #{format_datetime(result[:end])}"
130
- puts " Meet Link: #{result[:meet_link]}"
131
- puts " Event ID: #{result[:event_id]}"
132
- rescue RuntimeError => e
133
- warn "❌ Failed to get meeting URL: #{e.message}"
134
- exit 1
135
- end
128
+ puts "🎥 #{result[:summary] || "Meeting"}"
129
+ puts " Start: #{format_datetime(result[:start])}"
130
+ puts " End: #{format_datetime(result[:end])}"
131
+ puts " Meet Link: #{result[:meet_link]}"
132
+ puts " Event ID: #{result[:event_id]}"
133
+ rescue RuntimeError => e
134
+ warn "❌ Failed to get meeting URL: #{e.message}"
135
+ exit 1
136
+ end
136
137
 
137
- private
138
+ private
138
139
 
139
- def tool
140
- @tool ||= GmeetTool.new
141
- end
140
+ def tool
141
+ @tool ||= Service.new
142
+ end
142
143
 
143
- def format_datetime(datetime_info)
144
- return "Unknown" unless datetime_info
144
+ def format_datetime(datetime_info)
145
+ return "Unknown" unless datetime_info
145
146
 
146
- if datetime_info[:date]
147
- # All-day event
148
- datetime_info[:date]
149
- elsif datetime_info[:date_time]
150
- # Specific time event
151
- time = Time.parse(datetime_info[:date_time])
152
- time.strftime("%Y-%m-%d %H:%M")
153
- else
154
- "Unknown"
147
+ if datetime_info[:date]
148
+ # All-day event
149
+ datetime_info[:date]
150
+ elsif datetime_info[:date_time]
151
+ # Specific time event
152
+ time = Time.parse(datetime_info[:date_time])
153
+ time.strftime("%Y-%m-%d %H:%M")
154
+ else
155
+ "Unknown"
156
+ end
155
157
  end
156
158
  end
157
159
  end