edupage-cli 0.1.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 +7 -0
- data/README.md +250 -0
- data/exe/edupage +7 -0
- data/lib/edupage/account.rb +121 -0
- data/lib/edupage/cache.rb +101 -0
- data/lib/edupage/cli/formatter.rb +56 -0
- data/lib/edupage/cli/table.rb +136 -0
- data/lib/edupage/cli.rb +543 -0
- data/lib/edupage/client.rb +152 -0
- data/lib/edupage/config.rb +96 -0
- data/lib/edupage/credentials/env.rb +27 -0
- data/lib/edupage/credentials/keychain.rb +120 -0
- data/lib/edupage/credentials.rb +118 -0
- data/lib/edupage/errors.rb +74 -0
- data/lib/edupage/model.rb +131 -0
- data/lib/edupage/models/assignment.rb +83 -0
- data/lib/edupage/models/classroom.rb +13 -0
- data/lib/edupage/models/day.rb +51 -0
- data/lib/edupage/models/grade.rb +100 -0
- data/lib/edupage/models/lesson.rb +58 -0
- data/lib/edupage/models/parent.rb +8 -0
- data/lib/edupage/models/period.rb +13 -0
- data/lib/edupage/models/person.rb +25 -0
- data/lib/edupage/models/school_class.rb +16 -0
- data/lib/edupage/models/student.rb +45 -0
- data/lib/edupage/models/subject.rb +9 -0
- data/lib/edupage/models/teacher.rb +25 -0
- data/lib/edupage/models/term.rb +36 -0
- data/lib/edupage/models/timeline_item.rb +87 -0
- data/lib/edupage/parsers/base.rb +150 -0
- data/lib/edupage/parsers/gcall.rb +53 -0
- data/lib/edupage/parsers/timeline.rb +46 -0
- data/lib/edupage/parsers/userhome.rb +84 -0
- data/lib/edupage/parsers/znamky.rb +83 -0
- data/lib/edupage/registry/resources.rb +175 -0
- data/lib/edupage/registry.rb +286 -0
- data/lib/edupage/relation.rb +185 -0
- data/lib/edupage/school.rb +358 -0
- data/lib/edupage/serializer.rb +53 -0
- data/lib/edupage/server/api.rb +113 -0
- data/lib/edupage/server/auth.rb +45 -0
- data/lib/edupage/server/mcp.rb +107 -0
- data/lib/edupage/server.rb +57 -0
- data/lib/edupage/session.rb +166 -0
- data/lib/edupage/session_store.rb +131 -0
- data/lib/edupage/version.rb +3 -0
- data/lib/edupage/year.rb +77 -0
- data/lib/edupage.rb +64 -0
- metadata +201 -0
data/lib/edupage/cli.rb
ADDED
|
@@ -0,0 +1,543 @@
|
|
|
1
|
+
require "io/console"
|
|
2
|
+
require "json"
|
|
3
|
+
require "shellwords"
|
|
4
|
+
require "thor"
|
|
5
|
+
|
|
6
|
+
module Edupage
|
|
7
|
+
# Command line interface.
|
|
8
|
+
#
|
|
9
|
+
# Resource commands are not written by hand: they are generated from Registry, so the
|
|
10
|
+
# CLI gains a command the moment a resource is declared and its options always match
|
|
11
|
+
# the REST query parameters and the MCP input schema.
|
|
12
|
+
class CLI < Thor
|
|
13
|
+
TYPE_MAP = { integer: :numeric, boolean: :boolean }.freeze
|
|
14
|
+
|
|
15
|
+
def self.exit_on_failure? = true
|
|
16
|
+
|
|
17
|
+
class_option :username, type: :string, desc: "Edupage login (default: config default_username)"
|
|
18
|
+
class_option :school, type: :string, desc: "School origin (default: config default_school)"
|
|
19
|
+
class_option :student, type: :string, desc: "Student name or id"
|
|
20
|
+
class_option :year, type: :string, desc: "School year, e.g. 2025"
|
|
21
|
+
class_option :no_cache, type: :boolean, desc: "Ignore the cache and refetch"
|
|
22
|
+
class_option :json, type: :boolean, desc: "Output JSON"
|
|
23
|
+
class_option :yaml, type: :boolean, desc: "Output YAML"
|
|
24
|
+
class_option :verbose, type: :boolean, desc: "Log requests to stderr"
|
|
25
|
+
|
|
26
|
+
# --- generated resource commands ---------------------------------------------------
|
|
27
|
+
|
|
28
|
+
Registry.each do |resource|
|
|
29
|
+
desc resource.name.to_s, resource.summary
|
|
30
|
+
|
|
31
|
+
# Scope parameters are class options already; only the resource's own filters
|
|
32
|
+
# become per-command options.
|
|
33
|
+
resource.own_params.each do |param|
|
|
34
|
+
method_option param.name,
|
|
35
|
+
type: TYPE_MAP.fetch(param.type, :string),
|
|
36
|
+
desc: param.desc,
|
|
37
|
+
enum: param.enum? ? param.values : nil,
|
|
38
|
+
required: param.required
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
define_method(resource.name) do
|
|
42
|
+
run_resource(resource)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# --- credentials -------------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
desc "login", "Verify credentials and store the password in the macOS keychain"
|
|
49
|
+
method_option :username, type: :string, desc: "Edupage login (email)"
|
|
50
|
+
method_option :stdin, type: :boolean, desc: "Read the password from stdin instead of prompting"
|
|
51
|
+
def login
|
|
52
|
+
keychain = require_keychain!
|
|
53
|
+
username = options[:username] || Edupage.config.default_username ||
|
|
54
|
+
ask("Username (email):")
|
|
55
|
+
school = options[:school] || Edupage.config.default_school || ask("School (e.g. zsdemo):")
|
|
56
|
+
password = options[:stdin] ? $stdin.gets.to_s.chomp : ask_password
|
|
57
|
+
|
|
58
|
+
raise Error, "No password given" if password.empty?
|
|
59
|
+
|
|
60
|
+
# Verified before it is stored, so a typo never ends up in the keychain.
|
|
61
|
+
users = Client.mauth(school: school, username: username, password: password)
|
|
62
|
+
keychain.store(username: username, password: password)
|
|
63
|
+
|
|
64
|
+
say "Stored password for #{username}."
|
|
65
|
+
say "Schools: #{users.map { |u| u[:origin] }.join(", ")}"
|
|
66
|
+
remember_defaults(username, school)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
desc "logout", "Remove the stored password from the keychain"
|
|
70
|
+
method_option :username, type: :string
|
|
71
|
+
def logout
|
|
72
|
+
keychain = require_keychain!
|
|
73
|
+
username = options[:username] || Edupage.config.default_username or
|
|
74
|
+
raise MissingCredentialsError, "No username; pass --username."
|
|
75
|
+
|
|
76
|
+
say(keychain.delete(username: username) ? "Removed password for #{username}." : "Nothing stored for #{username}.")
|
|
77
|
+
say "Session cache is separate; use `edupage session logout` to drop it.", :yellow
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
desc "auth", "Show where credentials are coming from"
|
|
81
|
+
def auth
|
|
82
|
+
credentials = Credentials.new(username: options[:username], school: options[:school])
|
|
83
|
+
username = safe { credentials.username }
|
|
84
|
+
|
|
85
|
+
say Table.plain([
|
|
86
|
+
auth_row("username", username, credentials.username_source),
|
|
87
|
+
auth_row("school", safe { credentials.school }, credentials.school_source),
|
|
88
|
+
auth_row("password", credentials.password_source ? "(set)" : "(missing)",
|
|
89
|
+
credentials.password_source),
|
|
90
|
+
auth_row("keychain", keychain_status(username), nil)
|
|
91
|
+
])
|
|
92
|
+
|
|
93
|
+
return unless credentials.password_shadowed?
|
|
94
|
+
|
|
95
|
+
say "EDUPAGE_PASSWORD is set and overrides the keychain entry.", :yellow
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# --- session and cache ---------------------------------------------------------------
|
|
99
|
+
|
|
100
|
+
desc "session SUBCOMMAND", "status | refresh | logout"
|
|
101
|
+
def session(subcommand = "status")
|
|
102
|
+
store = SessionStore.new
|
|
103
|
+
username = Credentials.new(username: options[:username]).username
|
|
104
|
+
|
|
105
|
+
case subcommand
|
|
106
|
+
when "status"
|
|
107
|
+
entries = store.all(username)
|
|
108
|
+
return say("No stored sessions for #{username}.") if entries.empty?
|
|
109
|
+
|
|
110
|
+
rows = entries.map { |origin, entry| [origin, entry[:userid], "saved #{entry[:saved_at]}"] }
|
|
111
|
+
say Table.plain(rows)
|
|
112
|
+
when "refresh"
|
|
113
|
+
store.delete(username)
|
|
114
|
+
account = Edupage.account(username: username, school: options[:school])
|
|
115
|
+
say "Logged in again: #{account.schools.map(&:origin).join(", ")}"
|
|
116
|
+
when "logout"
|
|
117
|
+
store.delete(username)
|
|
118
|
+
say "Dropped stored sessions for #{username}. The keychain password is untouched."
|
|
119
|
+
else
|
|
120
|
+
raise Error, "Unknown session subcommand #{subcommand.inspect}"
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
desc "cache SUBCOMMAND", "info | clear"
|
|
125
|
+
def cache(subcommand = "info")
|
|
126
|
+
store = Cache.new
|
|
127
|
+
|
|
128
|
+
case subcommand
|
|
129
|
+
when "info"
|
|
130
|
+
entries = store.entries
|
|
131
|
+
say Table.plain([
|
|
132
|
+
["cache dir", ":", store.root],
|
|
133
|
+
["entries", ":", entries.size.to_s],
|
|
134
|
+
["size", ":", "#{entries.sum { |f| File.size(f) } / 1024} KiB"]
|
|
135
|
+
])
|
|
136
|
+
when "clear"
|
|
137
|
+
store.clear
|
|
138
|
+
say "Cache cleared."
|
|
139
|
+
else
|
|
140
|
+
raise Error, "Unknown cache subcommand #{subcommand.inspect}"
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
desc "config SUBCOMMAND [KEY] [VALUE]", "path | get | set"
|
|
145
|
+
def config(subcommand = "path", key = nil, value = nil)
|
|
146
|
+
case subcommand
|
|
147
|
+
when "path" then say Edupage.config.path
|
|
148
|
+
when "get" then say(key ? Edupage.config[key].inspect : Edupage.config.to_h.inspect)
|
|
149
|
+
when "set"
|
|
150
|
+
raise Error, "Usage: edupage config set KEY VALUE" if key.nil? || value.nil?
|
|
151
|
+
|
|
152
|
+
Edupage.config[key] = value
|
|
153
|
+
Edupage.config.save
|
|
154
|
+
say "#{key} = #{value}"
|
|
155
|
+
else
|
|
156
|
+
raise Error, "Unknown config subcommand #{subcommand.inspect}"
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# --- servers --------------------------------------------------------------------------
|
|
161
|
+
|
|
162
|
+
desc "server", "Serve the REST API and the MCP endpoint"
|
|
163
|
+
method_option :host, type: :string, desc: "Bind address (default 127.0.0.1)"
|
|
164
|
+
method_option :port, type: :numeric, desc: "Port (default 4567)"
|
|
165
|
+
method_option :token, type: :string, desc: "Bearer token (default: generated, stored in config)"
|
|
166
|
+
def server
|
|
167
|
+
Server.start(host: options[:host], port: options[:port], token: options[:token],
|
|
168
|
+
account_options: account_options)
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
desc "mcp", "Serve MCP over stdio, for editors and desktop clients"
|
|
172
|
+
def mcp
|
|
173
|
+
Server::MCP.new(account_options: account_options).run_stdio
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
MCP_TARGETS = %w[claude-code claude-desktop all].freeze
|
|
177
|
+
|
|
178
|
+
CLAUDE_DESKTOP_CONFIG =
|
|
179
|
+
"~/Library/Application Support/Claude/claude_desktop_config.json".freeze
|
|
180
|
+
|
|
181
|
+
desc "mcp-add TARGET", "Register this server with #{MCP_TARGETS.join(", ")}"
|
|
182
|
+
method_option :http, type: :boolean,
|
|
183
|
+
desc: "Register the HTTP endpoint of a running `edupage server` instead of stdio"
|
|
184
|
+
method_option :name, type: :string, desc: "Key under mcpServers (default: edupage)"
|
|
185
|
+
method_option :host, type: :string, desc: "Host for --http"
|
|
186
|
+
method_option :port, type: :numeric, desc: "Port for --http"
|
|
187
|
+
method_option :scope, type: :string, enum: %w[local user project],
|
|
188
|
+
desc: "Claude Code scope (default: local)"
|
|
189
|
+
method_option :dry_run, type: :boolean, aliases: "-n",
|
|
190
|
+
desc: "Show what would be done without registering anything"
|
|
191
|
+
map "mcp-add" => :mcp_add
|
|
192
|
+
def mcp_add(target = nil)
|
|
193
|
+
unless MCP_TARGETS.include?(target)
|
|
194
|
+
raise Thor::Error, "Usage: edupage mcp-add TARGET, where TARGET is #{MCP_TARGETS.join(", ")}"
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
name = options[:name] || "edupage"
|
|
198
|
+
entry = options[:http] ? http_entry : stdio_entry
|
|
199
|
+
targets = target == "all" ? MCP_TARGETS - ["all"] : [target]
|
|
200
|
+
|
|
201
|
+
targets.each do |each|
|
|
202
|
+
case each
|
|
203
|
+
when "claude-code" then add_to_claude_code(name, entry)
|
|
204
|
+
when "claude-desktop" then add_to_claude_desktop(name, entry)
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
desc "mcp-config", "Print an mcpServers entry for Claude Desktop or Claude Code"
|
|
210
|
+
method_option :http, type: :boolean,
|
|
211
|
+
desc: "Connect to a running `edupage server` over HTTP instead of stdio"
|
|
212
|
+
method_option :name, type: :string, desc: "Key under mcpServers (default: edupage)"
|
|
213
|
+
method_option :host, type: :string, desc: "Host for --http"
|
|
214
|
+
method_option :port, type: :numeric, desc: "Port for --http"
|
|
215
|
+
# Thor looks commands up by method name, so the hyphenated form needs mapping.
|
|
216
|
+
map "mcp-config" => :mcp_config
|
|
217
|
+
def mcp_config
|
|
218
|
+
name = options[:name] || "edupage"
|
|
219
|
+
entry = options[:http] ? http_entry : stdio_entry
|
|
220
|
+
|
|
221
|
+
# Nothing but the JSON, so `edupage mcp-config > entry.json` and piping into jq
|
|
222
|
+
# both work. Use `mcp-add --dry-run` to see what registering would do.
|
|
223
|
+
$stdout.puts(::JSON.pretty_generate("mcpServers" => { name => entry }))
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
desc "version", "Print the version"
|
|
227
|
+
def version = say(VERSION)
|
|
228
|
+
|
|
229
|
+
private
|
|
230
|
+
|
|
231
|
+
def run_resource(resource)
|
|
232
|
+
Edupage.logger.level = ::Logger::DEBUG if options[:verbose]
|
|
233
|
+
|
|
234
|
+
context = Registry::Context.new(
|
|
235
|
+
account: current_account, school: options[:school],
|
|
236
|
+
student: options[:student], year: options[:year]
|
|
237
|
+
)
|
|
238
|
+
result = resource.call(context, options)
|
|
239
|
+
|
|
240
|
+
# Only the table view gets the header: --json and --yaml stay byte-identical to
|
|
241
|
+
# what the REST API and the MCP tools return.
|
|
242
|
+
say(chain_header(resource, context)) if table_output? && !resource.scope_chain.empty?
|
|
243
|
+
formatter.render(result, resource: resource)
|
|
244
|
+
say(year_hint(resource, context), :yellow) if empty_year_result?(resource, context, result)
|
|
245
|
+
rescue AmbiguousScopeError => e
|
|
246
|
+
raise Thor::Error, choice_message(e)
|
|
247
|
+
rescue Error => e
|
|
248
|
+
raise Thor::Error, e.message
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
# Shows which school, student and year the answer came from, so a list is never
|
|
252
|
+
# ambiguous about whose it is.
|
|
253
|
+
def chain_header(resource, context)
|
|
254
|
+
rows = resource.scope_chain.map do |level|
|
|
255
|
+
case level
|
|
256
|
+
when :school then ["school", ":", "#{context.school.origin} (#{context.school.name})"]
|
|
257
|
+
when :student then ["student", ":", context.student.to_s]
|
|
258
|
+
when :year then ["year", ":", "#{context.year}#{context.year_defaulted? ? " [default]" : ""}"]
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
"#{Table.plain(rows)}\n"
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def choice_message(error)
|
|
266
|
+
rows = error.candidates.map { |c| ["--#{error.level}", c[:id].to_s, c[:label].to_s] }
|
|
267
|
+
|
|
268
|
+
"No #{error.level} selected. Pick one:\n#{Table.plain(rows, indent: 2)}"
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
# In September the current year is empty and last year's data is what was meant, so
|
|
272
|
+
# an empty defaulted year points at the years that do have something.
|
|
273
|
+
def year_hint(_resource, context)
|
|
274
|
+
elsewhere = context.student.years.reject(&:current?).select { |y| y.grade_count.positive? }
|
|
275
|
+
return "Nothing here for #{context.year}." if elsewhere.empty?
|
|
276
|
+
|
|
277
|
+
suggestions = elsewhere.first(3).map { |y| "--year #{y.id} (#{y.grade_count})" }
|
|
278
|
+
"Nothing here for #{context.year}. Try #{suggestions.join(", ")}."
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
def empty_year_result?(resource, context, result)
|
|
282
|
+
table_output? && resource.scope == :year && context.year_defaulted? &&
|
|
283
|
+
result.respond_to?(:empty?) && result.empty?
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
def table_output? = !options[:json] && !options[:yaml]
|
|
287
|
+
|
|
288
|
+
# Not named `account`: that is a generated command, and a private method of the
|
|
289
|
+
# same name would silently replace it.
|
|
290
|
+
def current_account
|
|
291
|
+
@current_account ||= Edupage.account(
|
|
292
|
+
username: options[:username], school: options[:school],
|
|
293
|
+
cache: Cache.new(enabled: !options[:no_cache])
|
|
294
|
+
)
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
# Defaults the long-running servers inherit from the command line, so
|
|
298
|
+
# `edupage server --username x --school y` needs no config file.
|
|
299
|
+
def account_options
|
|
300
|
+
{
|
|
301
|
+
username: options[:username],
|
|
302
|
+
school: options[:school],
|
|
303
|
+
student: options[:student],
|
|
304
|
+
year: options[:year]
|
|
305
|
+
}.compact
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
def formatter
|
|
309
|
+
Formatter.new(output: $stdout,
|
|
310
|
+
format: options[:json] ? :json : (options[:yaml] ? :yaml : :table))
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
def require_keychain!
|
|
314
|
+
unless Credentials::Keychain.available?
|
|
315
|
+
raise Thor::Error, "The macOS keychain is unavailable on #{RUBY_PLATFORM}; use EDUPAGE_PASSWORD."
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
Credentials::Keychain.new
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
def ask_password
|
|
322
|
+
$stderr.print "Password: "
|
|
323
|
+
password = $stdin.noecho(&:gets).to_s.chomp
|
|
324
|
+
$stderr.puts
|
|
325
|
+
password
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
def remember_defaults(username, school)
|
|
329
|
+
changed = false
|
|
330
|
+
if Edupage.config.default_username.nil?
|
|
331
|
+
Edupage.config["default_username"] = username
|
|
332
|
+
changed = true
|
|
333
|
+
end
|
|
334
|
+
if Edupage.config.default_school.nil?
|
|
335
|
+
Edupage.config["default_school"] = school
|
|
336
|
+
changed = true
|
|
337
|
+
end
|
|
338
|
+
return unless changed
|
|
339
|
+
|
|
340
|
+
Edupage.config.save
|
|
341
|
+
say "Saved defaults to #{Edupage.config.path}."
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
# --- mcp-config helpers -------------------------------------------------------------
|
|
345
|
+
|
|
346
|
+
# stdio needs no token: the client owns the process, so there is nothing to
|
|
347
|
+
# authenticate. It is the better default for a local tool.
|
|
348
|
+
def stdio_entry
|
|
349
|
+
entry = { "command" => executable_path, "args" => ["mcp"] }
|
|
350
|
+
|
|
351
|
+
if bundler_gemfile
|
|
352
|
+
# Running from a checkout rather than an installed gem. Going through bundler
|
|
353
|
+
# with an absolute BUNDLE_GEMFILE is what makes this work from any directory -
|
|
354
|
+
# MCP clients start servers with a working directory of their own choosing.
|
|
355
|
+
entry["command"] = bundler_path
|
|
356
|
+
entry["args"] = ["exec", executable_path, "mcp"]
|
|
357
|
+
entry["env"] = { "BUNDLE_GEMFILE" => bundler_gemfile }
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
# The account is authentication, not a level of the chain, so pinning it is safe;
|
|
361
|
+
# school and student stay out on purpose, so the model has to choose them.
|
|
362
|
+
username = safe { Credentials.new(username: options[:username]).username }
|
|
363
|
+
entry["args"] += ["--username", username] if username
|
|
364
|
+
entry
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
def http_entry
|
|
368
|
+
host = options[:host] || Edupage.config.server["host"] || Server::DEFAULT_HOST
|
|
369
|
+
port = options[:port] || Edupage.config.server["port"] || Server::DEFAULT_PORT
|
|
370
|
+
|
|
371
|
+
{
|
|
372
|
+
"type" => "http",
|
|
373
|
+
"url" => "http://#{host}:#{port}/mcp",
|
|
374
|
+
"headers" => { "Authorization" => "Bearer #{Edupage.config.server_token}" }
|
|
375
|
+
}
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
def executable_path
|
|
379
|
+
File.expand_path($PROGRAM_NAME)
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
def bundler_gemfile
|
|
383
|
+
gemfile = ENV["BUNDLE_GEMFILE"]
|
|
384
|
+
return File.expand_path(gemfile) if gemfile && File.exist?(gemfile)
|
|
385
|
+
|
|
386
|
+
nil
|
|
387
|
+
end
|
|
388
|
+
|
|
389
|
+
def bundler_path
|
|
390
|
+
which("bundle") || "bundle"
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
# Claude Code owns its own config, so registration goes through its CLI rather than
|
|
394
|
+
# editing the file behind its back.
|
|
395
|
+
def add_to_claude_code(name, entry)
|
|
396
|
+
command = claude_add_command(name, entry)
|
|
397
|
+
|
|
398
|
+
if options[:dry_run]
|
|
399
|
+
say "claude-code : would run"
|
|
400
|
+
say " #{command}"
|
|
401
|
+
return
|
|
402
|
+
end
|
|
403
|
+
|
|
404
|
+
raise Thor::Error, "`claude` is not on PATH; install Claude Code or use mcp-config" if which("claude").nil?
|
|
405
|
+
|
|
406
|
+
# Idempotent: `claude mcp add` refuses a name it already knows, so an existing
|
|
407
|
+
# entry is dropped first and re-added. Running this twice converges instead of
|
|
408
|
+
# failing the second time.
|
|
409
|
+
existed = claude_code_knows?(name)
|
|
410
|
+
system("claude", "mcp", "remove", name, *scope_arguments, out: File::NULL, err: File::NULL) if existed
|
|
411
|
+
|
|
412
|
+
raise Thor::Error, "`claude mcp add` failed" unless system(command, out: File::NULL)
|
|
413
|
+
|
|
414
|
+
say "claude-code : #{existed ? "updated" : "added"} #{name}#{scope_note}"
|
|
415
|
+
end
|
|
416
|
+
|
|
417
|
+
def claude_code_knows?(name)
|
|
418
|
+
system("claude", "mcp", "get", name, out: File::NULL, err: File::NULL)
|
|
419
|
+
end
|
|
420
|
+
|
|
421
|
+
def scope_arguments = options[:scope] ? ["-s", options[:scope]] : []
|
|
422
|
+
def scope_note = options[:scope] ? " (#{options[:scope]} scope)" : ""
|
|
423
|
+
|
|
424
|
+
# Claude Desktop has no CLI, so its JSON is merged by hand - preserving the other
|
|
425
|
+
# servers and the unrelated top-level keys it keeps in the same file.
|
|
426
|
+
def add_to_claude_desktop(name, entry)
|
|
427
|
+
path = File.expand_path(CLAUDE_DESKTOP_CONFIG)
|
|
428
|
+
config = read_json_file(path)
|
|
429
|
+
servers = config["mcpServers"] ||= {}
|
|
430
|
+
|
|
431
|
+
# Idempotent: an identical entry is left alone, a different one is brought into
|
|
432
|
+
# line, and a missing one is added.
|
|
433
|
+
current = servers[name]
|
|
434
|
+
action = if current.nil? then :added
|
|
435
|
+
elsif current == entry then :unchanged
|
|
436
|
+
else :updated
|
|
437
|
+
end
|
|
438
|
+
|
|
439
|
+
if options[:dry_run]
|
|
440
|
+
say "claude-desktop: would leave #{name.inspect} unchanged in #{path}" and return if action == :unchanged
|
|
441
|
+
|
|
442
|
+
say "claude-desktop: would #{action == :added ? "add" : "update"} #{name.inspect} in #{path}"
|
|
443
|
+
say ::JSON.pretty_generate(name => entry).gsub(/^/, " ")
|
|
444
|
+
return
|
|
445
|
+
end
|
|
446
|
+
|
|
447
|
+
return say "claude-desktop: #{name} already up to date" if action == :unchanged
|
|
448
|
+
|
|
449
|
+
servers[name] = entry
|
|
450
|
+
write_json_file(path, config)
|
|
451
|
+
say "claude-desktop: #{action} #{name} in #{path}"
|
|
452
|
+
say " restart Claude Desktop for it to pick this up", :yellow
|
|
453
|
+
end
|
|
454
|
+
|
|
455
|
+
def read_json_file(path)
|
|
456
|
+
return {} unless File.exist?(path)
|
|
457
|
+
|
|
458
|
+
# UTF-8 explicitly; other servers in this file may have non-ASCII values and the
|
|
459
|
+
# locale is not guaranteed to be set.
|
|
460
|
+
content = File.read(path, encoding: Encoding::UTF_8)
|
|
461
|
+
return {} if content.strip.empty?
|
|
462
|
+
|
|
463
|
+
::JSON.parse(content)
|
|
464
|
+
rescue ::JSON::ParserError => e
|
|
465
|
+
raise Thor::Error, "#{path} is not valid JSON (#{e.message}); fix or move it first"
|
|
466
|
+
end
|
|
467
|
+
|
|
468
|
+
# Writes via a temporary file and keeps one backup: this is a config the user owns
|
|
469
|
+
# and may have other servers in.
|
|
470
|
+
def write_json_file(path, data)
|
|
471
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
472
|
+
FileUtils.cp(path, "#{path}.bak") if File.exist?(path)
|
|
473
|
+
|
|
474
|
+
temp = "#{path}.#{Process.pid}.tmp"
|
|
475
|
+
File.open(temp, File::WRONLY | File::CREAT | File::TRUNC, 0o600) do |file|
|
|
476
|
+
file.set_encoding(Encoding::UTF_8)
|
|
477
|
+
file.write(::JSON.pretty_generate(data))
|
|
478
|
+
file.write("\n")
|
|
479
|
+
end
|
|
480
|
+
File.rename(temp, path)
|
|
481
|
+
end
|
|
482
|
+
|
|
483
|
+
# Windows finds `bundle` as bundle.bat, so the PATHEXT extensions are tried too.
|
|
484
|
+
def which(command)
|
|
485
|
+
extensions = [""] + ENV.fetch("PATHEXT", "").split(File::PATH_SEPARATOR)
|
|
486
|
+
ENV.fetch("PATH", "").split(File::PATH_SEPARATOR)
|
|
487
|
+
.product(extensions)
|
|
488
|
+
.map { |dir, ext| File.join(dir, command + ext) }
|
|
489
|
+
.find { |candidate| File.executable?(candidate) && !File.directory?(candidate) }
|
|
490
|
+
end
|
|
491
|
+
|
|
492
|
+
# Builds the equivalent `claude mcp add` invocation, shell-quoted so it can be
|
|
493
|
+
# pasted or piped straight into a shell.
|
|
494
|
+
#
|
|
495
|
+
# The two transports take different shapes: stdio passes the subprocess after `--`,
|
|
496
|
+
# while HTTP takes a URL and repeatable --header flags.
|
|
497
|
+
def claude_add_command(name, entry)
|
|
498
|
+
parts = ["claude", "mcp", "add"]
|
|
499
|
+
parts += ["-s", options[:scope]] if options[:scope]
|
|
500
|
+
|
|
501
|
+
if entry["type"] == "http"
|
|
502
|
+
parts += ["--transport", "http", name, entry["url"]]
|
|
503
|
+
entry.fetch("headers", {}).each { |key, value| parts += ["--header", "#{key}: #{value}"] }
|
|
504
|
+
else
|
|
505
|
+
# The name has to come before -e: `claude mcp add` takes --env variadically, so
|
|
506
|
+
# a name placed after it is swallowed as another KEY=value and rejected with
|
|
507
|
+
# "Invalid environment variable format".
|
|
508
|
+
parts << name
|
|
509
|
+
entry.fetch("env", {}).each { |key, value| parts += ["-e", "#{key}=#{value}"] }
|
|
510
|
+
parts += ["--", entry["command"], *entry["args"]]
|
|
511
|
+
end
|
|
512
|
+
|
|
513
|
+
parts.map { |part| shell_quote(part) }.join(" ")
|
|
514
|
+
end
|
|
515
|
+
|
|
516
|
+
# Shellwords.escape is correct but backslash-escapes every `=` and space, which
|
|
517
|
+
# makes the line hard to read. Only what actually needs quoting gets quoted.
|
|
518
|
+
SHELL_SAFE = %r{\A[A-Za-z0-9_@%+=:,./-]+\z}
|
|
519
|
+
|
|
520
|
+
def shell_quote(part)
|
|
521
|
+
return part if part.match?(SHELL_SAFE)
|
|
522
|
+
|
|
523
|
+
"'#{part.gsub("'", %q('\''))}'"
|
|
524
|
+
end
|
|
525
|
+
|
|
526
|
+
def keychain_status(username)
|
|
527
|
+
return "unavailable on #{RUBY_PLATFORM}" unless Credentials::Keychain.available?
|
|
528
|
+
return "available (service edupage-cli), but no username to look up" unless username
|
|
529
|
+
|
|
530
|
+
Credentials::Keychain.new.stored?(username: username) ? "stored for #{username}" : "nothing stored for #{username}"
|
|
531
|
+
end
|
|
532
|
+
|
|
533
|
+
def auth_row(label, value, source)
|
|
534
|
+
[label, ":", value || "(missing)", source ? "[#{source}]" : ""]
|
|
535
|
+
end
|
|
536
|
+
|
|
537
|
+
def safe
|
|
538
|
+
yield
|
|
539
|
+
rescue Error
|
|
540
|
+
nil
|
|
541
|
+
end
|
|
542
|
+
end
|
|
543
|
+
end
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
require "mechanize"
|
|
2
|
+
require "json"
|
|
3
|
+
|
|
4
|
+
module Edupage
|
|
5
|
+
# Raw HTTP against one Edupage origin.
|
|
6
|
+
#
|
|
7
|
+
# Redirects are deliberately NOT followed: an expired session answers a normal page
|
|
8
|
+
# request with `302 -> /login/...`, which is the cheapest reliable way to notice.
|
|
9
|
+
# Following it would instead yield a 40 KB login page that has to be sniffed for.
|
|
10
|
+
#
|
|
11
|
+
# Knows nothing about sessions or cursors; Session adds those on top.
|
|
12
|
+
class Client
|
|
13
|
+
USER_AGENT = "edupage-cli/#{VERSION} (+https://github.com/alhafoudh/edupage-cli)".freeze
|
|
14
|
+
MAUTH_PATH = "/login/mauth".freeze
|
|
15
|
+
|
|
16
|
+
# Mirrors the payload the official mobile client sends. Most fields are ignored by
|
|
17
|
+
# the server but omitting them changes the response shape.
|
|
18
|
+
MAUTH_PAYLOAD = {
|
|
19
|
+
"plgc" => "", "ajheslo" => "1", "hasujheslo" => "1", "ajportal" => "1",
|
|
20
|
+
"ajportallogin" => "1", "mobileLogin" => "1", "version" => "2020.0.18",
|
|
21
|
+
"device_name" => "", "device_id" => "", "device_key" => "", "os" => "",
|
|
22
|
+
"murl" => "", "edid" => ""
|
|
23
|
+
}.freeze
|
|
24
|
+
|
|
25
|
+
Response = Struct.new(:status, :body, :uri, keyword_init: true) do
|
|
26
|
+
def ok? = status.between?(200, 299)
|
|
27
|
+
|
|
28
|
+
# Both signals are verified against the live server: a dead session redirects
|
|
29
|
+
# page requests to /login/, and answers portalping with the literal "notlogged".
|
|
30
|
+
def expired?
|
|
31
|
+
return true if status == 302 && location_is_login?
|
|
32
|
+
return true if body.to_s.strip == "notlogged"
|
|
33
|
+
|
|
34
|
+
false
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def location_is_login? = uri.to_s.include?("/login/")
|
|
38
|
+
|
|
39
|
+
def json = JSON.parse(body)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
class << self
|
|
43
|
+
# Exchanges credentials for one session per school the account can reach.
|
|
44
|
+
#
|
|
45
|
+
# A single login can span several schools (a parent with children at two
|
|
46
|
+
# schools), and each entry carries its own esid - so this returns a list, not one
|
|
47
|
+
# session.
|
|
48
|
+
def mauth(school:, username:, password:)
|
|
49
|
+
# The login server is the school's own subdomain; the generic "login1" host
|
|
50
|
+
# rejects passwords for school-scoped accounts.
|
|
51
|
+
agent = build_agent
|
|
52
|
+
payload = MAUTH_PAYLOAD.merge(
|
|
53
|
+
"m" => username, "h" => password,
|
|
54
|
+
"edupage" => school.to_s, "fromEdupage" => school.to_s
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
page = agent.post("https://#{school}.edupage.org#{MAUTH_PATH}", payload)
|
|
58
|
+
data = JSON.parse(page.body)
|
|
59
|
+
|
|
60
|
+
users = data["users"] || []
|
|
61
|
+
if users.empty?
|
|
62
|
+
raise LoginError, data["needEdupage"] ? "Incorrect username" : "Incorrect password"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
users.map do |user|
|
|
66
|
+
{
|
|
67
|
+
userid: user["userid"],
|
|
68
|
+
first_name: user["first_name"] || user["firstname"],
|
|
69
|
+
last_name: user["last_name"] || user["lastname"],
|
|
70
|
+
origin: user["edupage"],
|
|
71
|
+
session_id: user["esid"],
|
|
72
|
+
role: user["typ"],
|
|
73
|
+
needs_2fa: user["need2fa"].to_s == "1"
|
|
74
|
+
}
|
|
75
|
+
end
|
|
76
|
+
rescue JSON::ParserError => e
|
|
77
|
+
raise LoginError, "Login endpoint returned something that is not JSON: #{e.message}"
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def build_agent
|
|
81
|
+
Mechanize.new do |a|
|
|
82
|
+
a.user_agent = USER_AGENT
|
|
83
|
+
a.redirect_ok = false
|
|
84
|
+
a.follow_meta_refresh = false
|
|
85
|
+
# Pages are 200-400 KB of HTML but every parser here works on the embedded
|
|
86
|
+
# <script> JSON via regex, so building a DOM would be pure waste.
|
|
87
|
+
a.pluggable_parser.default = Mechanize::File
|
|
88
|
+
a.pluggable_parser.html = Mechanize::File
|
|
89
|
+
a.open_timeout = 15
|
|
90
|
+
a.read_timeout = 60
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
attr_reader :origin
|
|
96
|
+
attr_accessor :session_id
|
|
97
|
+
|
|
98
|
+
def initialize(origin:, session_id: nil)
|
|
99
|
+
@origin = origin
|
|
100
|
+
@session_id = session_id
|
|
101
|
+
@agent = self.class.build_agent
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def base_url = "https://#{origin}.edupage.org"
|
|
105
|
+
|
|
106
|
+
def get(path)
|
|
107
|
+
request(:get, path)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def post(path, data = {})
|
|
111
|
+
request(:post, path, data)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
private
|
|
115
|
+
|
|
116
|
+
def request(method, path, data = nil)
|
|
117
|
+
url = path.start_with?("http") ? path : "#{base_url}#{path}"
|
|
118
|
+
|
|
119
|
+
page =
|
|
120
|
+
if method == :post
|
|
121
|
+
@agent.post(url, data || {}, headers)
|
|
122
|
+
else
|
|
123
|
+
@agent.get(url, [], nil, headers)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
Response.new(status: page.code.to_i, body: page.body, uri: response_location(page, url))
|
|
127
|
+
rescue Mechanize::ResponseCodeError => e
|
|
128
|
+
code = e.response_code.to_i
|
|
129
|
+
# 3xx arrives here when Mechanize declines to follow it.
|
|
130
|
+
return Response.new(status: code, body: e.page&.body.to_s, uri: response_location(e.page, url)) if code < 400
|
|
131
|
+
|
|
132
|
+
raise RequestError, "#{method.to_s.upcase} #{path} failed with HTTP #{code}"
|
|
133
|
+
rescue Mechanize::Error, Net::OpenTimeout, Net::ReadTimeout, SocketError, Errno::ECONNRESET => e
|
|
134
|
+
raise RequestError, "#{method.to_s.upcase} #{path} failed: #{e.class}: #{e.message}"
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def response_location(page, fallback)
|
|
138
|
+
location = page&.response&.[]("location")
|
|
139
|
+
location || fallback
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def headers
|
|
143
|
+
h = {
|
|
144
|
+
"Accept" => "application/json, text/javascript, */*; q=0.01",
|
|
145
|
+
"X-Requested-With" => "XMLHttpRequest",
|
|
146
|
+
"Referer" => "#{base_url}/"
|
|
147
|
+
}
|
|
148
|
+
h["Cookie"] = "PHPSESSID=#{session_id}" if session_id
|
|
149
|
+
h
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|