logi-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.
@@ -0,0 +1,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "uri"
6
+
7
+ module Logi
8
+ module Commands
9
+ # Device-code login for headless / SSH environments.
10
+ #
11
+ # Flow:
12
+ # 1. POST /cli/auth/device → receive user_code + device_code + verification_uri
13
+ # 2. Show the user_code and URL to the developer
14
+ # 3. Poll POST /cli/auth/device/poll every `interval` seconds
15
+ # 4. On success receive PAK → save credentials
16
+ module DeviceLogin
17
+ module_function
18
+
19
+ POLL_TIMEOUT = 600 # 10 minutes max
20
+
21
+ def perform(api_url:, portal_url: nil, **)
22
+ pastel = Pastel.new
23
+ portal = portal_url || ENV["LOGI_PORTAL_URL"] || Login::DEFAULT_PORTAL
24
+
25
+ # Step 1: issue device code
26
+ grant = request_device_code(api_url: api_url)
27
+ if grant.nil?
28
+ abort pastel.red("Couldn't get a device code. Check your connection to the server.")
29
+ end
30
+
31
+ user_code = grant["user_code"]
32
+ device_code = grant["device_code"]
33
+ verification_uri = grant["verification_uri"] || "#{portal}/cli/activate"
34
+ interval = (grant["interval"] || 5).to_i
35
+ expires_in = (grant["expires_in"] || 600).to_i
36
+
37
+ # Step 2: display to user
38
+ puts ""
39
+ puts pastel.bold("Open the URL below in your browser and enter the code.")
40
+ puts ""
41
+ puts " URL: " + pastel.cyan(verification_uri)
42
+ puts " Code: " + pastel.bold.yellow(user_code)
43
+ puts ""
44
+ puts pastel.dim("Waiting for approval… (expires in #{expires_in / 60} minutes)")
45
+ puts ""
46
+
47
+ # Step 3: poll
48
+ deadline = Time.now + POLL_TIMEOUT
49
+ loop do
50
+ sleep interval
51
+
52
+ if Time.now > deadline
53
+ abort pastel.red("Timed out. Try again from your terminal.")
54
+ end
55
+
56
+ result = poll(api_url: api_url, device_code: device_code)
57
+ next if result.nil?
58
+
59
+ error = result["error"]
60
+
61
+ case error
62
+ when nil
63
+ # Success — result contains token
64
+ save_and_print(result, pastel)
65
+ return result
66
+ when "authorization_pending"
67
+ next
68
+ when "slow_down"
69
+ interval += 5
70
+ next
71
+ when "access_denied"
72
+ abort pastel.red("Sign-in was declined.")
73
+ when "expired_token"
74
+ abort pastel.red("The code has expired. Please try again.")
75
+ else
76
+ abort pastel.red("Something went wrong: #{error} — #{result['error_description']}")
77
+ end
78
+ end
79
+ end
80
+
81
+ def request_device_code(api_url:)
82
+ uri = URI.join(api_url, "/cli/auth/device")
83
+ req = Net::HTTP::Post.new(uri.path,
84
+ "Content-Type" => "application/json",
85
+ "Accept" => "application/json"
86
+ )
87
+ req.body = "{}"
88
+ res = http(uri).request(req)
89
+ JSON.parse(res.body)
90
+ rescue => e
91
+ warn "Device authorization request failed: #{e.message}"
92
+ nil
93
+ end
94
+
95
+ def poll(api_url:, device_code:)
96
+ uri = URI.join(api_url, "/cli/auth/device/poll")
97
+ req = Net::HTTP::Post.new(uri.path,
98
+ "Content-Type" => "application/json",
99
+ "Accept" => "application/json"
100
+ )
101
+ req.body = JSON.generate(device_code: device_code)
102
+ res = http(uri).request(req)
103
+ JSON.parse(res.body)
104
+ rescue
105
+ nil
106
+ end
107
+
108
+ def save_and_print(token_response, pastel)
109
+ Config.save(
110
+ api_key: token_response["token"],
111
+ api_url: token_response["api_url"] || Login::DEFAULT_API,
112
+ name: "CLI (device)"
113
+ )
114
+
115
+ user = token_response["user"] || {}
116
+ puts pastel.green("✓ Signed in")
117
+ puts " Account: #{user["email_address"] || "(unknown)"}"
118
+ puts " Scopes: #{(token_response["scopes"] || []).join(", ")}"
119
+ puts " Saved: #{pastel.cyan(Config.path)}"
120
+ puts ""
121
+ puts pastel.bold("Next steps")
122
+ puts " " + pastel.cyan("logi apps list") + pastel.dim(" # List your apps")
123
+ puts " " + pastel.cyan("logi apps create --name \"My app\" -r https://app.example/cb") + pastel.dim(" # Register your first app")
124
+ puts " " + pastel.cyan("logi --help") + pastel.dim(" # All commands")
125
+ end
126
+
127
+ def http(uri)
128
+ Net::HTTP.new(uri.host, uri.port).tap do |h|
129
+ h.use_ssl = uri.scheme == "https"
130
+ end
131
+ end
132
+ end
133
+ end
134
+ end
@@ -0,0 +1,182 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "base64"
5
+ require "securerandom"
6
+ require "uri"
7
+ require "json"
8
+ require "net/http"
9
+ require "socket"
10
+ require "webrick"
11
+
12
+ module Logi
13
+ module Commands
14
+ # Browser-redirect OAuth login (PKCE + loopback). Same pattern as GitHub gh and vercel CLI.
15
+ #
16
+ # Flow:
17
+ # 1. Pick a free port, generate code_verifier + code_challenge.
18
+ # 2. Open browser → start.1pass.dev/cli/auth/start?code_challenge=…&port=…&state=…
19
+ # 3. Run a tiny WEBrick server on the port, wait for /cb?code=…&state=….
20
+ # 4. POST /cli/auth/exchange { code, code_verifier } → receive PAK.
21
+ # 5. Save credentials.
22
+ module Login
23
+ module_function
24
+
25
+ DEFAULT_PORTAL = "https://start.1pass.dev"
26
+ DEFAULT_API = "https://api.1pass.dev"
27
+
28
+ def perform(api_url:, name: "CLI", portal_url: nil, scopes: nil, **)
29
+ pastel = Pastel.new
30
+ portal = portal_url || ENV["LOGI_PORTAL_URL"] || DEFAULT_PORTAL
31
+ scopes ||= %w[apps:read apps:manage org:read profile:read]
32
+
33
+ port = pick_free_port
34
+ code_verifier = SecureRandom.urlsafe_base64(64).delete("=")
35
+ code_challenge = Base64.urlsafe_encode64(Digest::SHA256.digest(code_verifier), padding: false)
36
+ state = SecureRandom.hex(16)
37
+
38
+ authorize_url = build_authorize_url(portal, code_challenge, state, port, scopes)
39
+
40
+ puts pastel.dim("Opening your browser…")
41
+ puts pastel.cyan(" #{authorize_url}")
42
+ puts ""
43
+
44
+ open_browser(authorize_url)
45
+
46
+ result = wait_for_callback(port: port, expected_state: state)
47
+ unless result
48
+ abort pastel.red("Sign-in timed out or was cancelled.")
49
+ end
50
+
51
+ code = result[:code]
52
+ if code.nil? || code.empty?
53
+ abort pastel.red("Sign-in failed: no code was returned.")
54
+ end
55
+
56
+ puts pastel.dim("Exchanging the code for a token…")
57
+ token_response = exchange_code(api_url: api_url, code: code, verifier: code_verifier)
58
+
59
+ Config.save(
60
+ api_key: token_response["token"],
61
+ api_url: token_response["api_url"] || api_url,
62
+ name: name
63
+ )
64
+
65
+ user = token_response["user"] || {}
66
+ puts pastel.green("✓ Signed in")
67
+ puts " Account: #{user["email_address"] || "(unknown)"}"
68
+ puts " Scopes: #{(token_response["scopes"] || []).join(", ")}"
69
+ puts " Saved: #{pastel.cyan(Config.path)}"
70
+ puts ""
71
+ puts pastel.bold("Next steps")
72
+ puts " " + pastel.cyan("logi apps list") + pastel.dim(" # List your apps")
73
+ puts " " + pastel.cyan("logi apps create --name \"My app\" -r https://app.example/cb") + pastel.dim(" # Register your first app")
74
+ puts " " + pastel.cyan("logi --help") + pastel.dim(" # All commands")
75
+ token_response
76
+ end
77
+
78
+ def pick_free_port
79
+ server = TCPServer.new("127.0.0.1", 0)
80
+ port = server.addr[1]
81
+ server.close
82
+ port
83
+ end
84
+
85
+ def build_authorize_url(portal, code_challenge, state, port, scopes)
86
+ query = URI.encode_www_form(
87
+ code_challenge: code_challenge,
88
+ code_challenge_method: "S256",
89
+ state: state,
90
+ port: port,
91
+ scope: scopes.join(" ")
92
+ )
93
+ "#{portal}/cli/auth/start?#{query}"
94
+ end
95
+
96
+ def open_browser(url)
97
+ cmd =
98
+ case RbConfig::CONFIG["host_os"]
99
+ when /darwin/ then [ "open", url ]
100
+ when /mingw|mswin/ then [ "cmd", "/C", "start", "", url ]
101
+ else [ "xdg-open", url ]
102
+ end
103
+ Process.detach(spawn(*cmd, out: File::NULL, err: File::NULL))
104
+ rescue => e
105
+ warn "Couldn't open the browser automatically (#{e.message}). Please open the URL above manually."
106
+ end
107
+
108
+ # Run a WEBrick server until /cb is hit, return {code:, state:}.
109
+ def wait_for_callback(port:, expected_state:, timeout: 300)
110
+ result = nil
111
+ server = WEBrick::HTTPServer.new(
112
+ BindAddress: "127.0.0.1",
113
+ Port: port,
114
+ Logger: WEBrick::Log.new(File::NULL),
115
+ AccessLog: []
116
+ )
117
+
118
+ server.mount_proc("/cb") do |req, res|
119
+ got_state = req.query["state"]
120
+ got_code = req.query["code"]
121
+ if got_state == expected_state && got_code
122
+ result = { code: got_code, state: got_state }
123
+ res["Content-Type"] = "text/html; charset=utf-8"
124
+ res.body = success_html
125
+ else
126
+ res.status = 400
127
+ res["Content-Type"] = "text/plain; charset=utf-8"
128
+ res.body = "state mismatch or missing code"
129
+ end
130
+ Thread.new { sleep 0.3; server.shutdown }
131
+ end
132
+
133
+ Thread.new do
134
+ sleep timeout
135
+ server.shutdown if result.nil?
136
+ end
137
+
138
+ server.start
139
+ result
140
+ end
141
+
142
+ def exchange_code(api_url:, code:, verifier:)
143
+ uri = URI.join(api_url, "/cli/auth/exchange")
144
+ req = Net::HTTP::Post.new(uri.path, "Content-Type" => "application/json", "Accept" => "application/json")
145
+ req.body = JSON.generate(code: code, code_verifier: verifier)
146
+
147
+ res = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https") { |h| h.request(req) }
148
+ body = JSON.parse(res.body) rescue {}
149
+ unless res.code.to_i == 200
150
+ abort Pastel.new.red("Token exchange failed (#{res.code}): #{body["error"] || res.body}")
151
+ end
152
+ body
153
+ end
154
+
155
+ def success_html
156
+ <<~HTML
157
+ <!doctype html>
158
+ <html lang="en">
159
+ <head><meta charset="utf-8"><title>logi CLI</title>
160
+ <style>
161
+ body { font-family: -apple-system, BlinkMacSystemFont, sans-serif;
162
+ background: linear-gradient(135deg, #f5f7ff 0%, #f3f0ff 100%);
163
+ margin: 0; padding: 60px 20px; text-align: center; color: #1d1d1f; }
164
+ .card { display: inline-block; padding: 40px 56px; border-radius: 24px;
165
+ background: rgba(255,255,255,0.6); backdrop-filter: blur(20px);
166
+ border: 1px solid rgba(255,255,255,0.4); box-shadow: 0 4px 30px rgba(0,0,0,0.05); }
167
+ h1 { font-size: 28px; margin: 0 0 8px; }
168
+ p { color: #6e6e73; margin: 4px 0; }
169
+ .check { font-size: 48px; }
170
+ </style></head>
171
+ <body>
172
+ <div class="card">
173
+ <div class="check">✓</div>
174
+ <h1>You're signed in to logi CLI</h1>
175
+ <p>You can close this window.</p>
176
+ </div>
177
+ </body></html>
178
+ HTML
179
+ end
180
+ end
181
+ end
182
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Logi
4
+ module Commands
5
+ # JWT inspection. Signature verification fetches JWKS from the server.
6
+ class Token < Thor
7
+ desc "inspect JWT", "Decode a JWT access token (header + payload) and verify its signature against JWKS"
8
+ method_option :verify, type: :boolean, default: true,
9
+ desc: "Verify the signature (fetches JWKS)"
10
+ def inspect(jwt)
11
+ pastel = Pastel.new
12
+ segments = jwt.split(".")
13
+ if segments.size != 3
14
+ abort pastel.red("Not a valid JWT (expected header.payload.signature).")
15
+ end
16
+
17
+ header_json = JSON.parse(Base64.urlsafe_decode64(segments[0] + "=" * ((4 - segments[0].size % 4) % 4)))
18
+ payload_json = JSON.parse(Base64.urlsafe_decode64(segments[1] + "=" * ((4 - segments[1].size % 4) % 4)))
19
+
20
+ puts pastel.cyan("Header:")
21
+ puts JSON.pretty_generate(header_json)
22
+ puts pastel.cyan("\nPayload:")
23
+ puts JSON.pretty_generate(payload_json)
24
+
25
+ if options[:verify]
26
+ verify_with_jwks(jwt, header_json, payload_json)
27
+ end
28
+ end
29
+
30
+ no_commands do
31
+ def verify_with_jwks(jwt, header, payload)
32
+ pastel = Pastel.new
33
+ config = Config.load
34
+ http = HttpClient.new(base_url: config.api_url)
35
+ jwks = http.get("/.well-known/jwks.json")
36
+
37
+ kid = header["kid"]
38
+ jwk = (jwks["keys"] || []).find { |k| k["kid"] == kid }
39
+ if jwk.nil?
40
+ warn pastel.yellow("⚠ Signature check: kid=#{kid} was not found in JWKS")
41
+ return
42
+ end
43
+
44
+ public_key = build_rsa(jwk["n"], jwk["e"])
45
+ JWT.decode(jwt, public_key, true, algorithm: "RS256")
46
+ puts pastel.green("\n✓ Signature valid (kid=#{kid})")
47
+
48
+ exp = payload["exp"]
49
+ if exp && Time.at(exp) < Time.now
50
+ puts pastel.yellow("⚠ Expired (exp=#{Time.at(exp).iso8601})")
51
+ elsif exp
52
+ remaining = (Time.at(exp) - Time.now).to_i
53
+ puts pastel.green(" #{remaining} seconds until expiration")
54
+ end
55
+ rescue JWT::DecodeError => e
56
+ warn pastel.red("✗ Signature check failed: #{e.message}")
57
+ end
58
+
59
+ def build_rsa(n_b64, e_b64)
60
+ decode = ->(v) { OpenSSL::BN.new(Base64.urlsafe_decode64(v + "=" * ((4 - v.size % 4) % 4)), 2) }
61
+ # Build RSA from modulus+exponent via ASN.1
62
+ sequence = OpenSSL::ASN1::Sequence([
63
+ OpenSSL::ASN1::Integer(decode.call(n_b64)),
64
+ OpenSSL::ASN1::Integer(decode.call(e_b64))
65
+ ])
66
+ OpenSSL::PKey::RSA.new(sequence.to_der)
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Logi
4
+ # Load/save credentials.json. Default path: ~/.config/logi/credentials.json
5
+ # Environment variables LOGI_API_KEY / LOGI_API_URL take precedence when set.
6
+ class Config
7
+ DEFAULT_API_URL = "http://localhost:3000"
8
+
9
+ def self.path
10
+ File.expand_path(ENV.fetch("LOGI_CONFIG_PATH", "~/.config/logi/credentials.json"))
11
+ end
12
+
13
+ def self.load
14
+ env_key = ENV["LOGI_API_KEY"]
15
+ env_url = ENV["LOGI_API_URL"]
16
+ if env_key
17
+ return new(api_key: env_key, api_url: env_url || DEFAULT_API_URL, source: :env)
18
+ end
19
+
20
+ if File.exist?(path)
21
+ data = JSON.parse(File.read(path))
22
+ return new(api_key: data["api_key"], api_url: data["api_url"] || DEFAULT_API_URL,
23
+ source: :file, name: data["name"])
24
+ end
25
+
26
+ new(api_key: nil, api_url: env_url || DEFAULT_API_URL, source: :none)
27
+ end
28
+
29
+ def self.save(api_key:, api_url:, name:)
30
+ FileUtils.mkdir_p(File.dirname(path))
31
+ File.write(path, JSON.pretty_generate({ api_key: api_key, api_url: api_url, name: name }))
32
+ File.chmod(0o600, path)
33
+ end
34
+
35
+ def self.clear
36
+ File.delete(path) if File.exist?(path)
37
+ end
38
+
39
+ attr_reader :api_key, :api_url, :source, :name
40
+
41
+ def initialize(api_key:, api_url:, source:, name: nil)
42
+ @api_key = api_key
43
+ @api_url = api_url
44
+ @source = source
45
+ @name = name
46
+ end
47
+
48
+ def authenticated?
49
+ !api_key.nil? && !api_key.empty?
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Logi
4
+ # Minimal HTTP client using Net::HTTP. Keeps dependencies light.
5
+ class HttpClient
6
+ class Error < StandardError
7
+ attr_reader :status, :body
8
+
9
+ def initialize(status, body)
10
+ @status = status
11
+ @body = body
12
+ super("HTTP #{status}: #{body}")
13
+ end
14
+ end
15
+
16
+ def initialize(base_url:, api_key: nil, session_cookie: nil)
17
+ @base_url = base_url.chomp("/")
18
+ @api_key = api_key
19
+ @session_cookie = session_cookie
20
+ end
21
+
22
+ attr_accessor :session_cookie
23
+
24
+ def get(path, params: {})
25
+ request(:get, path, params: params)
26
+ end
27
+
28
+ def post(path, body: nil, params: {})
29
+ request(:post, path, body: body, params: params)
30
+ end
31
+
32
+ def patch(path, body: nil)
33
+ request(:patch, path, body: body)
34
+ end
35
+
36
+ def delete(path)
37
+ request(:delete, path)
38
+ end
39
+
40
+ private
41
+
42
+ def request(method, path, body: nil, params: {})
43
+ uri = URI("#{@base_url}#{path}")
44
+ uri.query = URI.encode_www_form(params) if params.any?
45
+
46
+ req = build_request(method, uri, body)
47
+ apply_auth!(req)
48
+
49
+ http = Net::HTTP.new(uri.host, uri.port)
50
+ http.use_ssl = uri.scheme == "https"
51
+
52
+ res = http.request(req)
53
+ capture_session_cookie!(res)
54
+ parse_response(res)
55
+ end
56
+
57
+ def build_request(method, uri, body)
58
+ klass = case method
59
+ when :get then Net::HTTP::Get
60
+ when :post then Net::HTTP::Post
61
+ when :patch then Net::HTTP::Patch
62
+ when :delete then Net::HTTP::Delete
63
+ end
64
+ req = klass.new(uri.request_uri)
65
+ if body
66
+ req["Content-Type"] = "application/json"
67
+ req.body = JSON.dump(body)
68
+ end
69
+ req["Accept"] = "application/json"
70
+ req
71
+ end
72
+
73
+ def apply_auth!(req)
74
+ req["Authorization"] = "Bearer #{@api_key}" if @api_key
75
+ req["Cookie"] = "session_id=#{@session_cookie}" if @session_cookie
76
+ end
77
+
78
+ def capture_session_cookie!(res)
79
+ set = res.get_fields("Set-Cookie") || []
80
+ set.each do |c|
81
+ if c.start_with?("session_id=")
82
+ @session_cookie = c.split(";").first.split("=", 2).last
83
+ end
84
+ end
85
+ end
86
+
87
+ def parse_response(res)
88
+ status = res.code.to_i
89
+ body = res.body.to_s.empty? ? {} : (JSON.parse(res.body) rescue { "raw" => res.body })
90
+
91
+ if status >= 400
92
+ raise Error.new(status, body)
93
+ end
94
+
95
+ body
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Logi
6
+ # Centralized output for human / JSON modes.
7
+ #
8
+ # Why: When an LLM (Claude, ChatGPT) drives the CLI, it can pass `--json`
9
+ # (or set `LOGI_OUTPUT=json`) to receive a stable, parseable shape on stdout.
10
+ # Human messages still go to stderr so they don't pollute pipes.
11
+ #
12
+ # Pattern: GitHub `gh --json fields`, Stripe CLI default JSON, Vercel `--output json`.
13
+ module Output
14
+ module_function
15
+
16
+ # Resolve mode from (in order):
17
+ # 1. ENV `LOGI_OUTPUT=json` or `=human`
18
+ # 2. options[:json] true → json
19
+ # 3. tty/pipe heuristic — pipe defaults to json (LLM-friendly)
20
+ # 4. fallback: human
21
+ def mode(options = {})
22
+ return ENV["LOGI_OUTPUT"] if %w[json human].include?(ENV["LOGI_OUTPUT"])
23
+ return "json" if options[:json]
24
+ "human"
25
+ end
26
+
27
+ def json?(options = {}); mode(options) == "json"; end
28
+
29
+ # Emit a structured success result. In human mode, runs the block instead.
30
+ def success(data, options = {}, &human_block)
31
+ if json?(options)
32
+ $stdout.puts JSON.generate(success: true, data: data)
33
+ elsif human_block
34
+ human_block.call
35
+ end
36
+ end
37
+
38
+ def failure(code:, message:, status: nil, options: {}, &human_block)
39
+ payload = { success: false, error: { code: code, message: message } }
40
+ payload[:error][:status] = status if status
41
+
42
+ if json?(options)
43
+ $stdout.puts JSON.generate(payload)
44
+ elsif human_block
45
+ human_block.call
46
+ else
47
+ warn message
48
+ end
49
+ end
50
+
51
+ # Pretty-print to stderr only when human (no-op in JSON mode).
52
+ # Use for progress chatter ("Opening your browser…" etc).
53
+ def chatter(text, options = {})
54
+ return if json?(options)
55
+ $stderr.puts text
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Logi
4
+ VERSION = "0.1.0"
5
+ end
data/lib/logi.rb ADDED
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "uri"
6
+ require "fileutils"
7
+ require "thor"
8
+ require "pastel"
9
+ require "tty-prompt"
10
+ require "tty-table"
11
+ require "jwt"
12
+ require "base64"
13
+ require "openssl"
14
+
15
+ require_relative "logi/version"
16
+ require_relative "logi/config"
17
+ require_relative "logi/http_client"
18
+ require_relative "logi/output"
19
+ require_relative "logi/commands/login"
20
+ require_relative "logi/commands/device_login"
21
+ require_relative "logi/commands/app"
22
+ require_relative "logi/commands/token"
23
+ require_relative "logi/cli"