logi-cli 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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e169d7a77d8a0d247460b3010da3abcc4925c47a579582d24efcc90839f8466e
4
- data.tar.gz: 6ad06a8bd52b41c8b5ebb5d12262edd848d4355519ed573bf47a15c22e9278ce
3
+ metadata.gz: fa3a61b59de983a4b5b5873e91456a8906ed221afb614665d5021a92524d13dd
4
+ data.tar.gz: d247641e4e0144646af75dff18f92fd57104c06088a646dc1b2965c20f70f73e
5
5
  SHA512:
6
- metadata.gz: 80c5570bc590c7224a8049aeecd116264de4b916e2e2a42af5ffe9b46f1cfbe089256c121f575a44d0ea99acda20ea00aa23ef3cdbb57e3a4e009d3b662a13b3
7
- data.tar.gz: 6d252d9bc5884d0da4c195423017e36689227200c312d0dd240cffed2478a87d3c02fbde246be8da849309a2ac0d6aae418384fb95767f19c2474c4be37d58a6
6
+ metadata.gz: 88f30a07076fd1869f156f2397c5027cd2caaaac28a923922d13a05352de8692c88413d13099b42ed2f1152e5338419470d4e4211b3cab8bc820f3a3609fdce2
7
+ data.tar.gz: f0cfc31661ecc9cbf9969371bf26afec6d6ac06a9bec17d6753d72dab4a1d397e5993186a6314a96eb4a65318d1ebaf76f9b29901232caa6301a0f2ceb4c8f1b
data/CHANGELOG.md CHANGED
@@ -6,6 +6,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.2.0] - 2026-07-06
10
+
11
+ ### Added
12
+ - `logi manual` — agent-oriented self-loading manual (the onpod `manual` pattern): key OAuth facts, full command catalog, and the 6-step RP registration flow in one offline-safe dump. `--full` additionally fetches the registration + integration runbook LLM splits from docs.1pass.dev.
13
+
9
14
  ## [0.1.0] - 2026-05-27
10
15
 
11
16
  Initial public release on RubyGems.org.
data/lib/logi/cli.rb CHANGED
@@ -23,6 +23,7 @@ module Logi
23
23
  puts " logi apps verify <id> -r URL # Check an RP integration (env / redirect URI)"
24
24
  puts " logi apps rotate-secret <id> # Rotate client_secret"
25
25
  puts " logi whoami # Show sign-in info"
26
+ puts " logi manual # Agent-oriented manual (paste into your AI agent)"
26
27
  puts " logi --help # All commands"
27
28
  else
28
29
  puts pastel.yellow("You're not signed in yet.")
@@ -49,21 +50,33 @@ module Logi
49
50
  method_option :api_url, type: :string, default: nil, desc: "logi API URL (default: https://api.1pass.dev)"
50
51
  method_option :portal_url, type: :string, default: nil, desc: "Developer Portal URL (default: https://start.1pass.dev)"
51
52
  method_option :name, type: :string, default: "CLI", desc: "PAK name"
52
- method_option :scope, type: :array, default: nil, desc: "Scopes to request"
53
- method_option :code, type: :boolean, default: false, desc: "Sign in with a code instead of a browser (for SSH / headless environments)"
53
+ method_option :scope, type: :array, default: nil, desc: "Scopes to request (clamped to the allowlist)"
54
+ method_option :code, type: :boolean, default: false, desc: "Headless: print a code instead of opening a browser (for SSH / no-GUI)"
55
+ method_option :loopback, type: :boolean, default: false, desc: "Use the loopback redirect flow (no code; same-device only)"
54
56
  def login
55
57
  api_url = options[:api_url] || ENV["LOGI_API_URL"] || Config::DEFAULT_API_URL
56
58
  portal_url = options[:portal_url] || ENV["LOGI_PORTAL_URL"]
57
59
 
58
- if options[:code]
59
- Commands::DeviceLogin.perform(api_url: api_url, portal_url: portal_url)
60
- else
60
+ if options[:loopback]
61
+ # Same-device loopback redirect (PKCE). No code to type; auto-completes
62
+ # via 127.0.0.1 callback. Supports custom --scope.
61
63
  Commands::Login.perform(
62
64
  api_url: api_url,
63
65
  portal_url: portal_url,
64
66
  name: options[:name],
65
67
  scopes: options[:scope]
66
68
  )
69
+ else
70
+ # Default: device flow. Opens the browser with the code pre-filled AND
71
+ # prints the code, so the user can approve here or on another device.
72
+ # `--code` skips the browser for headless / SSH environments.
73
+ Commands::DeviceLogin.perform(
74
+ api_url: api_url,
75
+ portal_url: portal_url,
76
+ name: options[:name],
77
+ scopes: options[:scope],
78
+ open_browser: !options[:code]
79
+ )
67
80
  end
68
81
  end
69
82
 
@@ -94,9 +107,19 @@ module Logi
94
107
  desc "app SUBCOMMAND", "(alias) same as `apps`", hide: true
95
108
  subcommand "app", Commands::App
96
109
 
110
+ desc "developer SUBCOMMAND", "Developer onboarding (register / status / verify-email)"
111
+ subcommand "developer", Commands::Developer
112
+
97
113
  desc "token SUBCOMMAND", "JWT access token tools (inspect)"
98
114
  subcommand "token", Commands::Token
99
115
 
116
+ desc "manual", "Print the agent-oriented manual (self-contained; --full appends the online runbooks)"
117
+ method_option :full, type: :boolean, default: false,
118
+ desc: "Also fetch and print the registration + integration runbooks (network required)"
119
+ def manual
120
+ Commands::Manual.perform(full: options[:full])
121
+ end
122
+
100
123
  def self.exit_on_failure?
101
124
  true
102
125
  end
@@ -46,25 +46,37 @@ module Logi
46
46
  method_option :scope, type: :array, default: [], aliases: "-s",
47
47
  desc: "Scopes to enable (e.g. -s openid profile:basic email). Optional."
48
48
  method_option :webhook_url, type: :string
49
+ method_option :backchannel_logout_uri, type: :string,
50
+ desc: "OIDC back-channel logout endpoint on your RP"
51
+ method_option :health_url, type: :string,
52
+ desc: "RP health endpoint logi pings hourly (must be public, no private IPs)"
53
+ method_option :health_check, type: :boolean, default: true,
54
+ desc: "Enable hourly RP health checks. Use --no-health-check to disable."
55
+ method_option :client_type, type: :string,
56
+ desc: "confidential (default) or public"
49
57
  def create
50
58
  data = authed_client.post("/api/v1/applications", body: {
51
59
  application: {
52
60
  name: options[:name],
53
61
  redirect_uris: [ options[:redirect_uri] ],
54
- allowed_scopes: options[:scope],
55
- webhook_url: options[:webhook_url]
62
+ # Flatten whitespace inside each --scope arg so BOTH `--scope openid profile email`
63
+ # and `--scope "openid profile email"` (Thor passes the quoted form as one element)
64
+ # resolve to separate scope names. Avoids a single bogus "openid profile email" scope.
65
+ allowed_scopes: Array(options[:scope]).flat_map { |s| s.to_s.split(/\s+/) }.reject(&:empty?),
66
+ webhook_url: options[:webhook_url],
67
+ backchannel_logout_uri: options[:backchannel_logout_uri],
68
+ health_url: options[:health_url],
69
+ # Only send health_check_enabled when explicitly opted out, so the
70
+ # server default (enabled) applies otherwise.
71
+ health_check_enabled: (options[:health_check] == false ? false : nil),
72
+ client_type: options[:client_type]
56
73
  }.compact
57
74
  })
58
75
  Output.success(data, options) do
59
- puts pastel.green("✓ App created")
60
- puts " client_id: #{data['client_id']}"
61
- puts " client_secret: #{pastel.yellow(data['client_secret'])}"
62
- puts pastel.dim(" ⚠ The secret is only shown once. Store it somewhere safe.")
76
+ print_create_result(data)
63
77
  end
64
78
  rescue HttpClient::Error => e
65
- Output.failure(code: "create_failed", message: e.body.to_s, status: e.status, options: options) do
66
- abort pastel.red("Couldn't create app: #{e.status} #{e.body}")
67
- end
79
+ handle_create_error(e)
68
80
  end
69
81
 
70
82
  desc "rotate-secret ID", "Rotate the client_secret"
@@ -127,10 +139,23 @@ module Logi
127
139
 
128
140
  # Check 2: tier
129
141
  tier = app["tier"]
130
- if tier == "production"
142
+ case tier
143
+ when "production"
131
144
  ok "Tier: production"
145
+ when nil
146
+ warn "Tier: unknown (server didn't report a tier)."
132
147
  else
133
- warn "Tier: sandbox — only localhost / staging redirects are allowed. To use a production URL, request a tier upgrade."
148
+ warn "Tier: #{tier} — only localhost / staging redirects are allowed. To use a production URL, request a tier upgrade."
149
+ end
150
+
151
+ # Health-check status (only shown when the server reports it).
152
+ unless app["health_check_enabled"].nil?
153
+ if app["health_check_enabled"]
154
+ status = app["rp_health_status"] || "unknown"
155
+ ok "Health checks: enabled (status: #{status})"
156
+ else
157
+ warn "Health checks: disabled"
158
+ end
134
159
  end
135
160
 
136
161
  # Check 3: redirect_uri registration
@@ -162,7 +187,8 @@ module Logi
162
187
  api_url = Config.load.api_url
163
188
  puts " LOGI_API_URL=#{api_url}"
164
189
  puts " LOGI_CLIENT_ID=#{app['client_id']}"
165
- puts " LOGI_CLIENT_SECRET=" + pastel.dim("(issued via rotate-secret)")
190
+ puts " LOGI_CLIENT_SECRET=" + pastel.dim("(shown once at create — or rotate-secret to re-issue)")
191
+ puts " LOGI_RP_HEALTH_SECRET=" + pastel.dim("(shown once at create)")
166
192
  puts ""
167
193
  puts pastel.dim("Docs: https://docs.1pass.dev/guide/troubleshooting")
168
194
  rescue HttpClient::Error => e
@@ -219,16 +245,67 @@ module Logi
219
245
  client_id: app["client_id"],
220
246
  tier: app["tier"],
221
247
  status: app["status"],
248
+ health_check_enabled: app["health_check_enabled"],
249
+ rp_health_status: app["rp_health_status"],
222
250
  redirect_uris: app["redirect_uris"],
223
251
  target_redirect_uri: target_uri,
224
252
  checks: checks,
225
253
  recommended_env: {
226
- "LOGI_API_URL" => Config.load.api_url,
227
- "LOGI_CLIENT_ID" => app["client_id"],
228
- "LOGI_CLIENT_SECRET" => "(rotate-secret to issue)"
254
+ "LOGI_API_URL" => Config.load.api_url,
255
+ "LOGI_CLIENT_ID" => app["client_id"],
256
+ "LOGI_CLIENT_SECRET" => "(shown once at create / rotate-secret)",
257
+ "LOGI_RP_HEALTH_SECRET" => "(shown once at create)"
229
258
  }
230
259
  }
231
260
  end
261
+
262
+ # Human-mode output for `apps create`: identifiers + the one-time
263
+ # secrets + a ready-to-paste env block. Secrets are NEVER written to a
264
+ # file by the CLI; the user copies them from the terminal.
265
+ def print_create_result(data)
266
+ puts pastel.green("✓ App created")
267
+ puts " client_id: #{data['client_id']}"
268
+ puts " status: #{data['status']}" if data["status"]
269
+ puts ""
270
+ if data["client_secret"]
271
+ puts " client_secret: #{pastel.yellow(data['client_secret'])}"
272
+ else
273
+ puts pastel.dim(" client_secret: (none — public client)")
274
+ end
275
+ if data["rp_health_secret"]
276
+ puts " rp_health_secret: #{pastel.yellow(data['rp_health_secret'])}"
277
+ end
278
+ puts ""
279
+ puts pastel.bold("Paste these into your RP's environment:")
280
+ puts pastel.dim("# secrets below are shown ONCE — store them now")
281
+ puts "LOGI_API_URL=#{Config.load.api_url}"
282
+ puts "LOGI_CLIENT_ID=#{data['client_id']}"
283
+ puts "LOGI_CLIENT_SECRET=#{data['client_secret']}" if data["client_secret"]
284
+ puts "LOGI_RP_HEALTH_SECRET=#{data['rp_health_secret']}" if data["rp_health_secret"]
285
+ puts ""
286
+ puts pastel.dim(" ⚠ The client_secret and rp_health_secret are only shown once. Store them somewhere safe.")
287
+ end
288
+
289
+ def handle_create_error(e)
290
+ code = e.body.is_a?(Hash) ? e.body["error"] : nil
291
+ error_code, message =
292
+ case code
293
+ when "identity_verification_required"
294
+ [ "identity_verification_required",
295
+ "Your email must be verified before registering an RP. Run: logi developer verify-email" ]
296
+ when "forbidden"
297
+ [ "forbidden",
298
+ "You need a developer or admin role first. Run: logi developer register" ]
299
+ else
300
+ [ "create_failed", "Couldn't create app: #{e.status} #{e.body}" ]
301
+ end
302
+ # Emit the actionable hint in BOTH human and --json modes, and always
303
+ # exit non-zero so automation never reads a 403 as success.
304
+ Output.failure(code: error_code, message: message, status: e.status, options: options) do
305
+ abort pastel.red(message.sub(". Run: ", "\n → "))
306
+ end
307
+ exit 1
308
+ end
232
309
  end
233
310
  end
234
311
  end
@@ -0,0 +1,195 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Logi
4
+ module Commands
5
+ # Developer self-serve: promote your account to a developer, check its
6
+ # identity-verification state, and verify your email with a code.
7
+ #
8
+ # Why: registering an RP (`logi apps create`) requires (1) a developer/admin
9
+ # role and (2) an identity-verified email. These commands let an AI agent or
10
+ # a human complete the whole onboarding from the terminal, no web console.
11
+ #
12
+ # All endpoints authenticate with the PAK (Bearer) — no special scope is
13
+ # required (same trust level as `promote_to_developer`).
14
+ class Developer < Thor
15
+ class_option :json, type: :boolean, default: false,
16
+ desc: "JSON output mode for LLM and CI workflows. You can also set LOGI_OUTPUT=json."
17
+
18
+ desc "register", "Promote your account to a developer (creates a personal org)"
19
+ method_option :email, type: :string,
20
+ desc: "Email to attach (required if your account has no email yet)"
21
+ method_option :org, type: :string,
22
+ desc: "Name for your personal organization"
23
+ def register
24
+ body = {
25
+ email_address: options[:email],
26
+ organization_name: options[:org]
27
+ }.compact
28
+
29
+ data = authed_client.post("/api/v1/me/promote_to_developer", body: body)
30
+ user = data["user"] || {}
31
+ org = data["organization"] || {}
32
+
33
+ Output.success(data, options) do
34
+ puts pastel.green("✓ You're a developer now")
35
+ puts " role: #{user['role']}"
36
+ puts " email: #{user['email_address'] || '-'}"
37
+ puts " organization: #{org['name'] || '-'}#{org['slug'] ? " (#{org['slug']})" : ''}"
38
+ puts ""
39
+ unless identity_verified?(user)
40
+ puts pastel.yellow(" Your email isn't verified yet — RP registration needs it.")
41
+ puts pastel.dim(" → logi developer verify-email")
42
+ end
43
+ end
44
+ rescue HttpClient::Error => e
45
+ handle_register_error(e)
46
+ end
47
+
48
+ desc "status", "Show your developer role and identity-verification state"
49
+ def status
50
+ data = authed_client.get("/api/v1/me")
51
+ user = data["user"] || {}
52
+
53
+ Output.success({ user: user }, options) do
54
+ puts pastel.bold("logi developer status")
55
+ puts ""
56
+ puts " role: #{user['role'] || '-'}"
57
+ puts " identity_verified_level: #{user['identity_verified_level'] || '-'}"
58
+ puts " email: #{user['email_address'] || '-'}"
59
+ puts ""
60
+ if identity_verified?(user)
61
+ ok "Identity verified — you can register RPs."
62
+ else
63
+ warn "Identity not verified — required before `logi apps create`."
64
+ puts pastel.dim(" → logi developer verify-email")
65
+ end
66
+ end
67
+ rescue HttpClient::Error => e
68
+ Output.failure(code: "status_failed", message: "Couldn't fetch status: #{e.status} #{e.body}",
69
+ status: e.status, options: options) do
70
+ abort pastel.red("Couldn't fetch status: #{e.status} #{e.body}")
71
+ end
72
+ exit 1
73
+ end
74
+
75
+ desc "verify-email", "Verify your email by entering a 6-digit code sent to your inbox"
76
+ def verify_email
77
+ client = authed_client
78
+
79
+ # Short-circuit: already verified → no email is sent.
80
+ me = client.get("/api/v1/me")["user"] || {}
81
+ if identity_verified?(me)
82
+ Output.success({ identity_verified_level: me["identity_verified_level"] }, options) do
83
+ puts pastel.green("✓ Your email is already verified.")
84
+ end
85
+ return
86
+ end
87
+
88
+ # In JSON / non-interactive mode the code comes from LOGI_VERIFY_CODE.
89
+ # Validate it BEFORE sending an email so an automation mistake doesn't
90
+ # trigger a wasted email + an out-of-contract abort.
91
+ preflight_code = noninteractive_code
92
+ if Output.json?(options) && (preflight_code.nil? || preflight_code.empty?)
93
+ Output.failure(
94
+ code: "code_required",
95
+ message: "Set LOGI_VERIFY_CODE with the 6-digit code in JSON mode.",
96
+ options: options
97
+ ) { abort pastel.red("Set LOGI_VERIFY_CODE with the 6-digit code in JSON mode.") }
98
+ exit 1
99
+ end
100
+
101
+ client.post("/api/v1/me/email_verifications")
102
+ Output.chatter(pastel.dim("A 6-digit code was sent to your email (valid 10 minutes)."), options)
103
+
104
+ code = preflight_code && !preflight_code.empty? ? preflight_code : read_code
105
+ data = client.post("/api/v1/me/email_verifications/verify_code", body: { code: code })
106
+
107
+ Output.success(data, options) do
108
+ puts pastel.green("✓ Email verified")
109
+ puts " identity_verified_level: #{data['identity_verified_level']}"
110
+ end
111
+ rescue HttpClient::Error => e
112
+ handle_verify_error(e)
113
+ end
114
+
115
+ no_commands do
116
+ def pastel
117
+ @pastel ||= Pastel.new
118
+ end
119
+
120
+ def authed_client
121
+ config = Config.load
122
+ unless config.authenticated?
123
+ abort pastel.red("You need to sign in. Run `logi login`.")
124
+ end
125
+ HttpClient.new(base_url: config.api_url, api_key: config.api_key)
126
+ end
127
+
128
+ def identity_verified?(user)
129
+ level = user["identity_verified_level"]
130
+ !level.nil? && level != "unverified"
131
+ end
132
+
133
+ # The code from LOGI_VERIFY_CODE (CI / agent path), or nil if unset.
134
+ def noninteractive_code
135
+ env_code = ENV["LOGI_VERIFY_CODE"]
136
+ env_code.nil? ? nil : env_code.strip
137
+ end
138
+
139
+ # Interactively prompt for the 6-digit code (human mode only — JSON mode
140
+ # is short-circuited to LOGI_VERIFY_CODE before this is reached).
141
+ def read_code
142
+ TTY::Prompt.new.ask("Enter the 6-digit code:") do |q|
143
+ q.required true
144
+ q.validate(/\A\d{6}\z/, "Enter exactly 6 digits.")
145
+ end
146
+ end
147
+
148
+ def ok(msg); puts pastel.green(" ✓ ") + msg; end
149
+ def warn(msg); puts pastel.yellow(" ⚠ ") + msg; end
150
+
151
+ def handle_register_error(e)
152
+ code = e.body.is_a?(Hash) ? e.body["error"] : nil
153
+ error_code, message =
154
+ case code
155
+ when "email_required"
156
+ [ "email_required", "An email is required. Pass --email you@example.com." ]
157
+ when "invalid_email"
158
+ [ "invalid_email", "That email looks invalid." ]
159
+ when "email_taken"
160
+ [ "email_taken", "That email is already in use." ]
161
+ else
162
+ [ "register_failed", "Couldn't register as a developer: #{e.status} #{e.body}" ]
163
+ end
164
+ Output.failure(code: error_code, message: message, status: e.status, options: options) do
165
+ abort pastel.red(message)
166
+ end
167
+ exit 1
168
+ end
169
+
170
+ def handle_verify_error(e)
171
+ code = e.body.is_a?(Hash) ? e.body["error"] : nil
172
+ error_code, message =
173
+ case code
174
+ when "no_deliverable_email"
175
+ [ "no_deliverable_email",
176
+ "No deliverable email on your account. Add a real email first: logi developer register --email you@example.com" ]
177
+ when "rate_limited"
178
+ [ "rate_limited", "A code was just sent — wait a moment before requesting another." ]
179
+ when "invalid_or_expired"
180
+ [ "invalid_or_expired",
181
+ "That code is invalid or expired. Run `logi developer verify-email` again for a new one." ]
182
+ when "too_many_attempts"
183
+ [ "too_many_attempts", "Too many attempts. Request a fresh code with `logi developer verify-email`." ]
184
+ else
185
+ [ "verify_email_failed", "Couldn't verify your email: #{e.status} #{e.body}" ]
186
+ end
187
+ Output.failure(code: error_code, message: message, status: e.status, options: options) do
188
+ abort pastel.red(message)
189
+ end
190
+ exit 1
191
+ end
192
+ end
193
+ end
194
+ end
195
+ end
@@ -1,16 +1,23 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "cgi"
3
4
  require "json"
4
5
  require "net/http"
5
6
  require "uri"
6
7
 
7
8
  module Logi
8
9
  module Commands
9
- # Device-code login for headless / SSH environments.
10
+ # Device-code login the default `logi login` flow.
11
+ #
12
+ # Unifies "approve in browser" and "enter a code" in one flow (like gh):
13
+ # by default we open the browser to verification_uri_complete (the code is
14
+ # pre-filled) AND print the code, so the user can approve on this device or
15
+ # type the code on another device / over SSH. `open_browser: false`
16
+ # (`logi login --code`) skips the browser for headless environments.
10
17
  #
11
18
  # 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
19
+ # 1. POST /cli/auth/device → receive user_code + device_code + verification_uri(_complete)
20
+ # 2. Open the browser (code pre-filled) and/or print the code + URL
14
21
  # 3. Poll POST /cli/auth/device/poll every `interval` seconds
15
22
  # 4. On success receive PAK → save credentials
16
23
  module DeviceLogin
@@ -18,29 +25,48 @@ module Logi
18
25
 
19
26
  POLL_TIMEOUT = 600 # 10 minutes max
20
27
 
21
- def perform(api_url:, portal_url: nil, **)
22
- pastel = Pastel.new
23
- portal = portal_url || ENV["LOGI_PORTAL_URL"] || Login::DEFAULT_PORTAL
28
+ def perform(api_url:, portal_url: nil, scopes: nil, name: "CLI", open_browser: true, **)
29
+ pastel = Pastel.new
30
+ portal_override = portal_url || ENV["LOGI_PORTAL_URL"]
31
+ portal = portal_override || Login::DEFAULT_PORTAL
24
32
 
25
33
  # Step 1: issue device code
26
- grant = request_device_code(api_url: api_url)
34
+ grant = request_device_code(api_url: api_url, scopes: scopes)
27
35
  if grant.nil?
28
36
  abort pastel.red("Couldn't get a device code. Check your connection to the server.")
29
37
  end
30
38
 
31
39
  user_code = grant["user_code"]
32
40
  device_code = grant["device_code"]
33
- verification_uri = grant["verification_uri"] || "#{portal}/cli/activate"
41
+ if portal_override
42
+ # Local / self-hosted: ignore the server-supplied (prod) URI and build
43
+ # the URL against the configured portal, so the browser opens the same
44
+ # server that minted this code instead of start.1pass.dev.
45
+ verification_uri = "#{portal}/cli/activate"
46
+ verification_uri_complete = "#{verification_uri}?user_code=#{CGI.escape(user_code.to_s)}"
47
+ else
48
+ verification_uri = grant["verification_uri"] || "#{portal}/cli/activate"
49
+ # Code pre-filled URL — fall back to building it if the server is older.
50
+ verification_uri_complete =
51
+ grant["verification_uri_complete"].to_s.empty? ?
52
+ "#{verification_uri}?user_code=#{CGI.escape(user_code.to_s)}" :
53
+ grant["verification_uri_complete"]
54
+ end
34
55
  interval = (grant["interval"] || 5).to_i
35
56
  expires_in = (grant["expires_in"] || 600).to_i
36
57
 
37
- # Step 2: display to user
58
+ # Step 2: present both paths — approve in browser OR enter the code.
38
59
  puts ""
39
- puts pastel.bold("Open the URL below in your browser and enter the code.")
60
+ puts pastel.bold("Sign in: approve in your browser, or enter the code on another device.")
40
61
  puts ""
41
- puts " URL: " + pastel.cyan(verification_uri)
42
62
  puts " Code: " + pastel.bold.yellow(user_code)
63
+ puts " URL: " + pastel.cyan(verification_uri)
43
64
  puts ""
65
+ if open_browser
66
+ puts pastel.dim("Opening your browser… (the code is pre-filled — just approve)")
67
+ Login.open_browser(verification_uri_complete)
68
+ puts ""
69
+ end
44
70
  puts pastel.dim("Waiting for approval… (expires in #{expires_in / 60} minutes)")
45
71
  puts ""
46
72
 
@@ -61,7 +87,7 @@ module Logi
61
87
  case error
62
88
  when nil
63
89
  # Success — result contains token
64
- save_and_print(result, pastel)
90
+ save_and_print(result, pastel, name: name)
65
91
  return result
66
92
  when "authorization_pending"
67
93
  next
@@ -78,13 +104,14 @@ module Logi
78
104
  end
79
105
  end
80
106
 
81
- def request_device_code(api_url:)
107
+ def request_device_code(api_url:, scopes: nil)
82
108
  uri = URI.join(api_url, "/cli/auth/device")
83
109
  req = Net::HTTP::Post.new(uri.path,
84
110
  "Content-Type" => "application/json",
85
111
  "Accept" => "application/json"
86
112
  )
87
- req.body = "{}"
113
+ body = scopes ? { scope: Array(scopes).join(" ") } : {}
114
+ req.body = JSON.generate(body)
88
115
  res = http(uri).request(req)
89
116
  JSON.parse(res.body)
90
117
  rescue => e
@@ -105,11 +132,11 @@ module Logi
105
132
  nil
106
133
  end
107
134
 
108
- def save_and_print(token_response, pastel)
135
+ def save_and_print(token_response, pastel, name: "CLI")
109
136
  Config.save(
110
137
  api_key: token_response["token"],
111
138
  api_url: token_response["api_url"] || Login::DEFAULT_API,
112
- name: "CLI (device)"
139
+ name: name
113
140
  )
114
141
 
115
142
  user = token_response["user"] || {}
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Logi
4
+ module Commands
5
+ # `logi manual` — agent-first self-loading manual.
6
+ #
7
+ # One command that loads usage + the command catalog + RP-integration
8
+ # entry points into an AI agent's context (the onpod `manual` pattern):
9
+ # docs prompts can say "run `logi manual`" instead of pointing agents at
10
+ # multiple doc URLs. The embedded text is self-contained and offline-safe;
11
+ # `--full` additionally fetches the two runbook LLM splits from docs.
12
+ module Manual
13
+ DOCS_BASE = "https://docs.1pass.dev"
14
+ RUNBOOKS = {
15
+ "registration runbook" => "/llms/guide/cli-rp-registration.md",
16
+ "integration runbook" => "/llms/guide/agent-quickstart.md"
17
+ }.freeze
18
+
19
+ # Plain text on purpose — no colors/TTY layout. The primary reader is
20
+ # an AI agent ingesting stdout; humans get the same text fine.
21
+ EMBEDDED = <<~MANUAL
22
+ # logi CLI — agent manual
23
+
24
+ logi (1pass) is an OIDC Identity Provider. This manual is designed to be
25
+ loaded into an AI agent's context via `logi manual`. It is self-contained
26
+ for registering an RP (OAuth app) and starting a login integration.
27
+
28
+ ## Key facts
29
+ - Issuer / OAuth host: https://api.1pass.dev — the id_token `iss` is this
30
+ literal. The console host start.1pass.dev is NOT an OAuth host
31
+ (discovery/jwks return 404 there).
32
+ - Discovery: https://api.1pass.dev/.well-known/openid-configuration
33
+ - Valid scopes: openid profile:basic email (bare `profile` is rejected)
34
+ - redirect_uri matches byte-for-byte. `apps create` takes exactly one;
35
+ add the rest (local / staging / prod / mobile scheme) with
36
+ `apps add-redirect`.
37
+ - client_secret / rp_health_secret are shown ONCE at create — put them in
38
+ the deploy env, never in code/git. Missed them? Rotate in the developer
39
+ console (https://start.1pass.dev) — CLI/API rotation is refused with
40
+ 403 use_web_portal because it requires web OTP step-up.
41
+ - --client-type: confidential (backend can hide the secret) or public
42
+ (mobile/SPA, PKCE-only, no client_secret). Immutable after create.
43
+
44
+ ## Command catalog
45
+ logi login Sign in (browser OAuth; --code for headless/SSH)
46
+ logi whoami Show sign-in info
47
+ logi developer register --email <e> --org "<o>" Become a developer
48
+ logi developer status Check identity_verified_level
49
+ logi developer verify-email Enter the 6-digit email code (SSO signups skip this)
50
+ logi apps create --name "<n>" --redirect-uri <url> \\
51
+ --scope openid profile:basic email --client-type <confidential|public>
52
+ logi apps add-redirect <id> <uri>
53
+ logi apps list | show <id> | verify <id> | delete <id>
54
+ (secret rotation: developer console only — web OTP step-up required)
55
+ logi token inspect <jwt> Decode + verify a JWT against JWKS
56
+ logi manual [--full] This manual (--full appends both runbooks)
57
+
58
+ ## RP registration flow (6 steps)
59
+ 1. logi login
60
+ 2. logi developer register --email <e> --org "<o>"
61
+ 3. logi developer status → if unverified: logi developer verify-email
62
+ 4. logi apps create ... (client_id + secrets issued once)
63
+ 5. Inject env: LOGI_API_URL / LOGI_CLIENT_ID / LOGI_CLIENT_SECRET (confidential only) / LOGI_RP_HEALTH_SECRET
64
+ 6. logi apps verify <id> (production domains go `pending` → admin review)
65
+
66
+ ## Deep docs (fetch on demand)
67
+ - Registration runbook: #{DOCS_BASE}/llms/guide/cli-rp-registration.md
68
+ - Integration runbook (track branching + F1-F8 pitfalls):
69
+ #{DOCS_BASE}/llms/guide/agent-quickstart.md
70
+ - English editions: swap /llms/ for /llms-en/
71
+ - Index of every topic: #{DOCS_BASE}/llms.txt
72
+
73
+ Run `logi manual --full` to print both runbooks inline (network required).
74
+ MANUAL
75
+
76
+ def self.perform(full: false)
77
+ puts EMBEDDED
78
+ return unless full
79
+
80
+ RUNBOOKS.each do |label, path|
81
+ body = fetch(path)
82
+ if body
83
+ puts "\n\n---\n# [#{label}] #{DOCS_BASE}#{path}\n\n"
84
+ puts body
85
+ else
86
+ warn "⚠ Could not fetch #{label} (#{DOCS_BASE}#{path}) — network unavailable? " \
87
+ "Fetch it yourself with: curl #{DOCS_BASE}#{path}"
88
+ end
89
+ end
90
+ end
91
+
92
+ def self.fetch(path)
93
+ uri = URI("#{DOCS_BASE}#{path}")
94
+ res = Net::HTTP.start(uri.host, uri.port, use_ssl: true, open_timeout: 5, read_timeout: 10) do |http|
95
+ http.get(uri.path)
96
+ end
97
+ res.is_a?(Net::HTTPSuccess) ? res.body : nil
98
+ rescue StandardError
99
+ nil
100
+ end
101
+ private_class_method :fetch
102
+ end
103
+ end
104
+ end
data/lib/logi/config.rb CHANGED
@@ -4,7 +4,11 @@ module Logi
4
4
  # Load/save credentials.json. Default path: ~/.config/logi/credentials.json
5
5
  # Environment variables LOGI_API_KEY / LOGI_API_URL take precedence when set.
6
6
  class Config
7
- DEFAULT_API_URL = "http://localhost:3000"
7
+ # Production default. The shipped gem must point at prod out of the box —
8
+ # otherwise `logi login` issues the authorize request to start.1pass.dev
9
+ # but POSTs the token exchange to localhost, which 404s for every user.
10
+ # Local development overrides this with LOGI_API_URL=http://localhost:3000.
11
+ DEFAULT_API_URL = "https://api.1pass.dev"
8
12
 
9
13
  def self.path
10
14
  File.expand_path(ENV.fetch("LOGI_CONFIG_PATH", "~/.config/logi/credentials.json"))
@@ -67,6 +67,8 @@ module Logi
67
67
  req.body = JSON.dump(body)
68
68
  end
69
69
  req["Accept"] = "application/json"
70
+ # Identify the CLI so the server records created_via=cli (not "api").
71
+ req["X-Logi-Client-Type"] = "cli"
70
72
  req
71
73
  end
72
74
 
data/lib/logi/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Logi
4
- VERSION = "0.1.0"
4
+ VERSION = "0.2.0"
5
5
  end
data/lib/logi.rb CHANGED
@@ -19,5 +19,7 @@ require_relative "logi/output"
19
19
  require_relative "logi/commands/login"
20
20
  require_relative "logi/commands/device_login"
21
21
  require_relative "logi/commands/app"
22
+ require_relative "logi/commands/developer"
22
23
  require_relative "logi/commands/token"
24
+ require_relative "logi/commands/manual"
23
25
  require_relative "logi/cli"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: logi-cli
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dcode
@@ -110,8 +110,10 @@ files:
110
110
  - lib/logi.rb
111
111
  - lib/logi/cli.rb
112
112
  - lib/logi/commands/app.rb
113
+ - lib/logi/commands/developer.rb
113
114
  - lib/logi/commands/device_login.rb
114
115
  - lib/logi/commands/login.rb
116
+ - lib/logi/commands/manual.rb
115
117
  - lib/logi/commands/token.rb
116
118
  - lib/logi/config.rb
117
119
  - lib/logi/http_client.rb