knoxcall 0.0.1 → 1.0.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.
Files changed (56) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE +201 -0
  3. data/README.md +439 -2
  4. data/exe/knoxcall +8 -0
  5. data/lib/knoxcall/bootstrap.rb +70 -0
  6. data/lib/knoxcall/bound_route.rb +47 -0
  7. data/lib/knoxcall/cli/ai.rb +79 -0
  8. data/lib/knoxcall/cli/ai_control.rb +275 -0
  9. data/lib/knoxcall/cli/common.rb +96 -0
  10. data/lib/knoxcall/cli/init.rb +94 -0
  11. data/lib/knoxcall/cli/login.rb +306 -0
  12. data/lib/knoxcall/cli/logout.rb +41 -0
  13. data/lib/knoxcall/cli/whoami.rb +29 -0
  14. data/lib/knoxcall/cli.rb +377 -0
  15. data/lib/knoxcall/client.rb +1025 -0
  16. data/lib/knoxcall/credentials_file.rb +442 -0
  17. data/lib/knoxcall/dpop.rb +79 -0
  18. data/lib/knoxcall/egress_observations.rb +372 -0
  19. data/lib/knoxcall/errors.rb +304 -0
  20. data/lib/knoxcall/intercept_patch.rb +181 -0
  21. data/lib/knoxcall/intercept_pipeline.rb +455 -0
  22. data/lib/knoxcall/intercept_resolver.rb +140 -0
  23. data/lib/knoxcall/intercept_store.rb +203 -0
  24. data/lib/knoxcall/login.rb +144 -0
  25. data/lib/knoxcall/resources/account.rb +12 -0
  26. data/lib/knoxcall/resources/agents.rb +25 -0
  27. data/lib/knoxcall/resources/ai_gateway.rb +417 -0
  28. data/lib/knoxcall/resources/api_keys.rb +35 -0
  29. data/lib/knoxcall/resources/audit_logs.rb +45 -0
  30. data/lib/knoxcall/resources/clients.rb +39 -0
  31. data/lib/knoxcall/resources/crypto.rb +122 -0
  32. data/lib/knoxcall/resources/dynamic_db.rb +68 -0
  33. data/lib/knoxcall/resources/environments.rb +16 -0
  34. data/lib/knoxcall/resources/logs.rb +51 -0
  35. data/lib/knoxcall/resources/oauth_clients.rb +34 -0
  36. data/lib/knoxcall/resources/opportunities.rb +61 -0
  37. data/lib/knoxcall/resources/pki.rb +41 -0
  38. data/lib/knoxcall/resources/roles.rb +27 -0
  39. data/lib/knoxcall/resources/routes.rb +53 -0
  40. data/lib/knoxcall/resources/secrets.rb +98 -0
  41. data/lib/knoxcall/resources/unwraps_envelope.rb +90 -0
  42. data/lib/knoxcall/resources/vaults.rb +77 -0
  43. data/lib/knoxcall/resources/webhooks.rb +48 -0
  44. data/lib/knoxcall/resources/workflows.rb +86 -0
  45. data/lib/knoxcall/resources/wrap.rb +352 -0
  46. data/lib/knoxcall/route_refusal.rb +67 -0
  47. data/lib/knoxcall/signup.rb +122 -0
  48. data/lib/knoxcall/token_exchange.rb +169 -0
  49. data/lib/knoxcall/ulid.rb +19 -0
  50. data/lib/knoxcall/warnings.rb +63 -0
  51. data/lib/knoxcall/workload_provider.rb +192 -0
  52. data/lib/knoxcall/wrap_faraday_adapter.rb +119 -0
  53. data/lib/knoxcall/wrap_faraday_middleware.rb +67 -0
  54. data/lib/knoxcall/wrap_transport.rb +139 -0
  55. data/lib/knoxcall.rb +45 -1
  56. metadata +70 -9
@@ -0,0 +1,70 @@
1
+ module KnoxCall
2
+ # Bootstrap credential types. The +type+ discriminator defaults per class,
3
+ # so callers never need to spell it out. Secret fields are excluded from
4
+ # #inspect so a logged client or captured exception context never prints
5
+ # the secret (Object#to_s is already safe — it shows only class + object id).
6
+
7
+ class AccessToken
8
+ attr_reader :type
9
+
10
+ def initialize(access_token:)
11
+ @access_token = access_token
12
+ @type = "access_token"
13
+ end
14
+
15
+ def access_token = @access_token
16
+
17
+ def inspect = "#<KnoxCall::AccessToken access_token=[REDACTED]>"
18
+ end
19
+
20
+ class OIDCTokenExchange
21
+ attr_reader :issuer, :type
22
+
23
+ def initialize(subject_token:, issuer:)
24
+ @subject_token = subject_token
25
+ @issuer = issuer
26
+ @type = "oidc_token_exchange"
27
+ end
28
+
29
+ def subject_token = @subject_token
30
+
31
+ def inspect = "#<KnoxCall::OIDCTokenExchange issuer=#{@issuer.inspect} subject_token=[REDACTED]>"
32
+ end
33
+
34
+ class ClientCredentials
35
+ attr_reader :client_id, :type
36
+
37
+ def initialize(client_id:, client_secret:)
38
+ @client_id = client_id
39
+ @client_secret = client_secret
40
+ @type = "client_credentials"
41
+ end
42
+
43
+ def client_secret = @client_secret
44
+
45
+ def inspect = "#<KnoxCall::ClientCredentials client_id=#{@client_id.inspect} client_secret=[REDACTED]>"
46
+ end
47
+
48
+ # Credentials file written by `knoxcall login` (PARITY §2).
49
+ #
50
+ # Holds no secrets itself — tokens are read from the file (path/profile
51
+ # resolved from KNOXCALL_CREDENTIALS_FILE / KNOXCALL_PROFILE when not
52
+ # given) at token-fetch time, so #inspect stays safe by construction.
53
+ class StoredCredentials
54
+ attr_reader :path, :profile, :type
55
+
56
+ def initialize(path: nil, profile: nil)
57
+ @path = path
58
+ @profile = profile
59
+ @type = "stored_credentials"
60
+ end
61
+
62
+ def inspect = "#<KnoxCall::StoredCredentials path=#{@path.inspect} profile=#{@profile.inspect}>"
63
+ end
64
+
65
+ # Deprecated aliases — the pre-release *Bootstrap names used by the other
66
+ # KnoxCall SDKs. Remove before 2.0.
67
+ AccessTokenBootstrap = AccessToken
68
+ OidcTokenExchangeBootstrap = OIDCTokenExchange
69
+ ClientCredentialsBootstrap = ClientCredentials
70
+ end
@@ -0,0 +1,47 @@
1
+ module KnoxCall
2
+ # A route with bound call defaults — see Client#route.
3
+ #
4
+ # printnode = client.route("3f1e2c9a-...", environment: "production")
5
+ # computers = JSON.parse(printnode.get("/computers").body)
6
+ # printnode.post("/printjobs", body: payload)
7
+ #
8
+ # Holds only the client reference, route id, and defaults (never a token
9
+ # or any pipeline state), so retries and 401 re-mint behave exactly as on
10
+ # Client#call. Per-call values win over bound defaults; headers merge
11
+ # per-key with per-call winning. A nil per-call value inherits the bound
12
+ # default — there is no "explicitly clear" mechanism; construct another
13
+ # handle instead.
14
+ class BoundRoute
15
+ def initialize(client, route, environment: nil, headers: {}, timeout: nil)
16
+ @client = client
17
+ @route = route
18
+ @environment = environment
19
+ @headers = (headers || {}).dup.freeze
20
+ @timeout = timeout
21
+ freeze
22
+ end
23
+
24
+ def request(method, path = "/", body: nil, headers: {}, environment: nil, query: nil, timeout: nil)
25
+ @client.call(
26
+ @route,
27
+ method: method,
28
+ path: path,
29
+ body: body,
30
+ headers: @headers.merge(headers || {}),
31
+ environment: environment.nil? ? @environment : environment,
32
+ query: query,
33
+ timeout: timeout.nil? ? @timeout : timeout
34
+ )
35
+ end
36
+
37
+ def get(path = "/", **kw) = request("GET", path, **kw)
38
+ def post(path = "/", **kw) = request("POST", path, **kw)
39
+ def put(path = "/", **kw) = request("PUT", path, **kw)
40
+ def patch(path = "/", **kw) = request("PATCH", path, **kw)
41
+ def delete(path = "/", **kw) = request("DELETE", path, **kw)
42
+
43
+ def inspect
44
+ "#<KnoxCall::BoundRoute route=#{@route.inspect} environment=#{@environment.inspect}>"
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,79 @@
1
+ module KnoxCall
2
+ module CLI
3
+ # `knoxcall ai exchange` — RFC 8693 workload federation from a terminal.
4
+ #
5
+ # Mirrors knoxcall-python's knoxcall/cli/ai.py (the PARITY §13 reference):
6
+ # same flags, same messages, same exit codes.
7
+ #
8
+ # The one KnoxCall command that needs no `knoxcall login` and no KnoxCall
9
+ # credential at all: the CI workload's own OIDC id_token IS the credential,
10
+ # and the server verifies it against the issuer's published JWKS.
11
+ #
12
+ # export KC_TOKEN="$(knoxcall ai exchange --tenant acme)"
13
+ #
14
+ # Two rules this command exists to enforce, because both are easy to get
15
+ # wrong in a CI script and neither fails in a way that names itself:
16
+ #
17
+ # 1. The subject token is read from the environment, NEVER a flag. An argv
18
+ # value lands in shell history, in +ps+ output, and in the CI log line
19
+ # that echoes the command. Same rule +knoxcall init+ applies to
20
+ # KNOXCALL_WRAP_SECRET.
21
+ # 2. The host is the tenant data plane, and there is no default. On
22
+ # api.knoxcall.com this endpoint answers 401, which reads as "my CI
23
+ # token was rejected" and sends people hunting through their issuer's
24
+ # JWKS.
25
+ #
26
+ # Only the token goes to stdout, so <tt>$(...)</tt> captures exactly the
27
+ # token.
28
+ module Ai
29
+ # The subject token is read from here, never from argv. See rule 1 above.
30
+ SUBJECT_TOKEN_ENV = "KNOXCALL_SUBJECT_TOKEN".freeze
31
+
32
+ module_function
33
+
34
+ def run(options)
35
+ subject_token = (ENV[SUBJECT_TOKEN_ENV] || "").strip
36
+ if subject_token.empty?
37
+ raise Error,
38
+ "#{SUBJECT_TOKEN_ENV} is not set — put your CI provider's OIDC id_token there " \
39
+ "(a flag would land in shell history, ps output and the CI log). GitHub Actions: " \
40
+ "request one with `id-token: write` and the ACTIONS_ID_TOKEN_REQUEST_URL endpoint, " \
41
+ 'audience "knoxcall:gateway".'
42
+ end
43
+
44
+ tenant = options[:tenant]
45
+ base_url = options[:base_url]
46
+ if (tenant.nil? || tenant.empty?) && (base_url.nil? || base_url.empty?)
47
+ raise Error,
48
+ "one of --tenant or --base-url is required: POST /v1/oauth/token is served only on " \
49
+ "the tenant data-plane host (https://{tenant}.knoxcall.com). Pointing it at " \
50
+ "api.knoxcall.com answers 401, which reads like a rejected subject_token but means " \
51
+ "the endpoint is not there."
52
+ end
53
+
54
+ kwargs = {
55
+ subject_token: subject_token,
56
+ tenant: tenant,
57
+ sandbox: options[:sandbox] == true,
58
+ base_url: base_url
59
+ }
60
+ # `resource` is only passed when the flag was GIVEN: the SDK
61
+ # distinguishes nil from an empty string, and an empty one is a server
62
+ # refusal rather than "no resource".
63
+ kwargs[:resource] = options[:resource] if options.key?(:resource)
64
+ kwargs[:audience] = options[:audience] if options[:audience]
65
+
66
+ result = KnoxCall.exchange_token(**kwargs)
67
+ token = result["access_token"]
68
+ raise Error, "the exchange returned no access_token" if token.nil? || token.empty?
69
+
70
+ # stdout: the token, nothing else. stderr: everything a human wants.
71
+ puts token
72
+ kind = options.key?(:resource) ? "tool (MCP, resource-bound)" : "agent"
73
+ ttl = result["expires_in"].is_a?(Integer) ? ", valid #{result['expires_in']}s" : ""
74
+ warn "exchanged for a #{kind} token#{ttl}"
75
+ 0
76
+ end
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,275 @@
1
+ module KnoxCall
2
+ module CLI
3
+ # `knoxcall ai` — the AI-gateway CONTROL plane from a terminal (AIGW-162).
4
+ #
5
+ # Mirrors the Node reference (sdk/knoxcall-node/src/cli/ai-control.ts):
6
+ # same flags, same messages, same stdout/stderr split, same exit codes.
7
+ #
8
+ # `ai exchange` (ai.rb) is the data-plane door: it needs no login, because
9
+ # the CI workload's OIDC token IS the credential. Everything here is the
10
+ # opposite — it acts as the signed-in tenant, through the same
11
+ # ~/.knoxcall/credentials.json profile `login` writes and `whoami` reads.
12
+ #
13
+ # WHY THIS EXISTS. Until now the five SDK CLIs shipped exactly one `ai`
14
+ # sub-command, `exchange`. A capable `knoxcall ai gateways|agents|mint|usage`
15
+ # lived in a standalone `cli/` package that was never published, never
16
+ # tested, never in CI and not in the workspaces — and it could not create a
17
+ # secret, a gateway or an agent, so it could not get you to a first call
18
+ # either. So there was no CLI golden path at all: the only way from "I have
19
+ # an API key" to "my app is calling an LLM through KnoxCall" was the browser
20
+ # or hand-written HTTP.
21
+ #
22
+ # The golden path these commands exist to make true, from a tenant with
23
+ # nothing in it:
24
+ #
25
+ # export ANTHROPIC_API_KEY=sk-ant-...
26
+ # knoxcall ai create-agent --name copilot --slug copilot \
27
+ # --provider anthropic --secret-from-env ANTHROPIC_API_KEY
28
+ # knoxcall ai mint --agent <id>
29
+ # curl "$AGENT_URL/v1/messages" -H "Authorization: Bearer $TOKEN" ...
30
+ #
31
+ # Two commands, then a real streamed call. +create-agent+ prints the agent
32
+ # id, the +agent_url+ and the exact next command, so the path is
33
+ # discoverable without re-reading the docs.
34
+ #
35
+ # THREE RULES, each one a bug this shape invites:
36
+ #
37
+ # 1. A PROVIDER KEY IS NEVER AN ARGV VALUE. +--secret-from-env NAME+ names
38
+ # the environment variable to read; there is deliberately no
39
+ # +--secret-value+. An argv value lands in shell history, in +ps+
40
+ # output and in the CI log line that echoes the command. Same rule
41
+ # +ai exchange+ applies to KNOXCALL_SUBJECT_TOKEN and +init+ to
42
+ # KNOXCALL_WRAP_SECRET.
43
+ #
44
+ # 2. NO POSITIONAL ARGUMENTS. Four of the five SDK CLIs hand-roll their
45
+ # parser and reject positionals outright; only python gets them free
46
+ # from argparse. Ids are flags (+--gateway+, +--agent+) so the surface
47
+ # is the same in all five rather than "the same except in Ruby".
48
+ #
49
+ # 3. AN AGENT WITHOUT AN UPSTREAM IS REFUSED HERE, not at its first call.
50
+ # The API accepts create_agent with no provider/upstream_secret_id and
51
+ # stores an agent whose first data-plane request 502s (AIGW-161). A
52
+ # command whose entire purpose is "get me to a working call" must not
53
+ # be able to produce that, so +--provider+ and one of +--secret+ /
54
+ # +--secret-from-env+ are required together.
55
+ #
56
+ # No HTTP lives here: every call goes through the typed SDK resources
57
+ # (client.ai_gateway, client.secrets).
58
+ module AiControl
59
+ module_function
60
+
61
+ # A client acting as the signed-in tenant, or a refusal telling them to
62
+ # log in. Obtained exactly the way `whoami` and `init` obtain theirs.
63
+ def client_for(options)
64
+ path = CredentialsFile.resolve_path
65
+ profile = CredentialsFile.resolve_profile(options[:profile])
66
+ if CredentialsFile.read_profile(path, profile).nil?
67
+ raise Error, "not logged in (profile '#{profile}') — run `knoxcall login`"
68
+ end
69
+
70
+ client_opts = { bootstrap: StoredCredentials.new(path: path, profile: profile) }
71
+ client_opts[:base_url] = options[:base_url] if presence(options[:base_url])
72
+ client_opts[:sandbox] = true if options[:sandbox]
73
+ Client.new(**client_opts)
74
+ end
75
+
76
+ def presence(value) = CredentialsFile.presence(value)
77
+
78
+ def required(value, flag)
79
+ found = presence(value)
80
+ raise Error, "#{flag} is required" if found.nil?
81
+
82
+ found
83
+ end
84
+
85
+ # -- knoxcall ai gateways -------------------------------------------------
86
+
87
+ def gateways(options)
88
+ client = client_for(options)
89
+ rows = client.ai_gateway.list_gateways(per_page: 100)["data"] || []
90
+ if rows.empty?
91
+ warn "No AI gateways. `knoxcall ai create-agent` will create one for you."
92
+ return 0
93
+ end
94
+
95
+ rows.each { |g| puts "#{g['id']} #{g['slug']} #{g['name']}" }
96
+ 0
97
+ end
98
+
99
+ # -- knoxcall ai agents --gateway ID --------------------------------------
100
+
101
+ def agents(options)
102
+ gateway_id = required(options[:gateway], "--gateway")
103
+ client = client_for(options)
104
+ rows = client.ai_gateway.list_agents(gateway_id, per_page: 100)["data"] || []
105
+ if rows.empty?
106
+ warn "No agents in that gateway."
107
+ return 0
108
+ end
109
+
110
+ # agent_url is on every projection since AIGW-161, so a list is enough
111
+ # to point an SDK at an existing agent — no follow-up GET.
112
+ rows.each { |a| puts "#{a['id']} #{a['slug']} #{a['agent_url']}" }
113
+ 0
114
+ end
115
+
116
+ # -- knoxcall ai create-agent ---------------------------------------------
117
+
118
+ def create_agent(options)
119
+ slug = required(options[:slug], "--slug")
120
+ # Rule 3: refuse here rather than let the API store an agent with no
121
+ # upstream whose first data-plane call 502s.
122
+ provider = required(options[:provider], "--provider")
123
+ if presence(options[:secret]).nil? && presence(options[:secret_from_env]).nil?
124
+ raise Error,
125
+ "one of --secret or --secret-from-env is required: an agent created without an " \
126
+ "upstream credential is accepted by the API and 502s on its first call."
127
+ end
128
+
129
+ client = client_for(options)
130
+ gateway_id = resolve_gateway(client, options[:gateway])
131
+ secret_id = resolve_secret(client, options)
132
+
133
+ body = {
134
+ name: presence(options[:name]) || slug,
135
+ slug: slug,
136
+ provider: provider,
137
+ upstream_secret_id: secret_id
138
+ }
139
+ body[:upstream] = options[:upstream] if presence(options[:upstream])
140
+ body[:default_model] = options[:model] if presence(options[:model])
141
+ agent = client.ai_gateway.create_agent(gateway_id, **body)
142
+
143
+ # stdout: the agent id, so $(...) captures exactly that. Everything a
144
+ # human needs next goes to stderr, including the command that follows.
145
+ puts agent["id"]
146
+ warn "\n agent: #{agent['slug']} (#{agent['id']})"
147
+ warn " gateway: #{gateway_id}"
148
+ warn " provider: #{provider}"
149
+ warn " base_url: #{agent['agent_url']}" if presence(agent["agent_url"])
150
+ warn "\n Next: knoxcall ai mint --agent #{agent['id']}"
151
+ 0
152
+ end
153
+
154
+ # Resolve the gateway to create under.
155
+ #
156
+ # +--gateway+ takes an id OR a slug. With no +--gateway+: use the tenant's
157
+ # only gateway, or create one when they have none — that is what makes the
158
+ # command work on a fresh tenant, which is the whole point. With SEVERAL
159
+ # and no flag it refuses and lists them rather than picking: "whichever
160
+ # sorts first" is how the quickstart wizard silently landed a second agent
161
+ # in the wrong gateway.
162
+ def resolve_gateway(client, wanted)
163
+ rows = client.ai_gateway.list_gateways(per_page: 100)["data"] || []
164
+ wanted = presence(wanted)
165
+ if wanted
166
+ hit = rows.find { |g| g["id"] == wanted || g["slug"] == wanted }
167
+ raise Error, "no gateway '#{wanted}' — this tenant has: #{gateway_list(rows)}" if hit.nil?
168
+
169
+ return hit["id"]
170
+ end
171
+ return rows.first["id"] if rows.length == 1
172
+
173
+ if rows.empty?
174
+ created = client.ai_gateway.create_gateway(name: "Default", slug: "default")
175
+ warn "created gateway #{created['slug']} (#{created['id']})"
176
+ return created["id"]
177
+ end
178
+
179
+ raise Error,
180
+ "--gateway is required: this tenant has #{rows.length} gateways " \
181
+ "(#{gateway_list(rows)}). Picking one for you would put the agent somewhere " \
182
+ "you did not choose."
183
+ end
184
+
185
+ def gateway_list(rows)
186
+ listed = rows.map { |g| "#{g['slug']} (#{g['id']})" }.join(", ")
187
+ listed.empty? ? "none" : listed
188
+ end
189
+
190
+ # Resolve the upstream secret, escrowing one from the environment if asked.
191
+ #
192
+ # The key is read from ENV[NAME], never from a flag — see rule 1.
193
+ # Re-running with the same +--secret-from-env+ reuses the existing secret
194
+ # by name rather than creating a second copy of the same credential.
195
+ def resolve_secret(client, options)
196
+ secret = presence(options[:secret])
197
+ return secret if secret
198
+
199
+ env_name = required(options[:secret_from_env], "--secret or --secret-from-env")
200
+ value = (ENV[env_name] || "").strip
201
+ if value.empty?
202
+ raise Error,
203
+ "#{env_name} is not set — put your provider key there. There is deliberately no " \
204
+ "--secret-value flag: an argv value lands in shell history, ps output and the CI log."
205
+ end
206
+
207
+ name = "ai-gateway-#{presence(options[:slug]) || 'agent'}-key"
208
+ hit = (client.secrets.list(per_page: 100)["data"] || []).find { |s| s["name"] == name }
209
+ if hit
210
+ warn "reusing secret '#{name}' (#{hit['id']})"
211
+ return hit["id"]
212
+ end
213
+
214
+ created = client.secrets.create(name: name, value: value)
215
+ warn "escrowed secret '#{name}' (#{created['id']}) — the key is now in KnoxCall custody"
216
+ created["id"]
217
+ end
218
+
219
+ # -- knoxcall ai mint --agent ID ------------------------------------------
220
+
221
+ def mint(options)
222
+ agent_id = required(options[:agent], "--agent")
223
+ client = client_for(options)
224
+ body = {}
225
+ body[:kind] = options[:kind] if presence(options[:kind])
226
+ body[:name] = options[:name] if presence(options[:name])
227
+ minted = client.ai_gateway.mint_token(agent_id, **body)
228
+
229
+ # The plaintext is returned ONCE. stdout carries only the token so
230
+ # `> token.txt` captures the token and nothing else; the metadata and
231
+ # the warning go to stderr.
232
+ puts minted["token"]
233
+ warn "\n id: #{minted['id']}"
234
+ warn " kind: #{minted['kind']}"
235
+ warn " prefix: #{minted['prefix']}"
236
+ warn " dpop: #{minted['dpop_required']}"
237
+ warn " expires: #{minted['expires_at'] || 'never'}"
238
+ warn "\n Save this token now — it will not be shown again."
239
+ 0
240
+ end
241
+
242
+ # -- knoxcall ai usage ----------------------------------------------------
243
+
244
+ def usage(options)
245
+ client = client_for(options)
246
+ agent_id = presence(options[:agent])
247
+ params = { period: presence(options[:period]) || "30d" }
248
+ params[:agent_id] = agent_id if agent_id
249
+ rollup = client.ai_gateway.usage(**params)
250
+ totals = rollup["totals"] || {}
251
+
252
+ puts "Usage — last #{rollup['period_days']} days#{agent_id ? " (agent #{agent_id})" : ''}"
253
+ puts " requests: #{totals['requests']}"
254
+ puts " input tokens: #{totals['input_tokens']}"
255
+ puts " output tokens: #{totals['output_tokens']}"
256
+ puts format(" cost (USD): %.4f", totals["cost_usd"].to_f)
257
+ puts " unpriced: #{totals['unpriced_requests']}"
258
+
259
+ by_model = rollup["by_model"] || []
260
+ if by_model.empty?
261
+ puts "\nNo usage in this period."
262
+ return 0
263
+ end
264
+
265
+ puts "\nBy model:"
266
+ by_model.each do |m|
267
+ puts format(" %s/%s %s req in %s out %s $%.4f",
268
+ m["provider"], m["model"], m["requests"],
269
+ m["input_tokens"], m["output_tokens"], m["cost_usd"].to_f)
270
+ end
271
+ 0
272
+ end
273
+ end
274
+ end
275
+ end
@@ -0,0 +1,96 @@
1
+ require "json"
2
+ require "net/http"
3
+ require "uri"
4
+ require "fileutils"
5
+
6
+ module KnoxCall
7
+ module CLI
8
+ # Reserved alias accepted by /oauth/authorize and the device endpoints; the
9
+ # server lazily provisions the tenant's real CLI client and returns its id
10
+ # as the +client_id+ extension member on the token response.
11
+ CLI_CLIENT_ID = "knoxcall-cli"
12
+
13
+ # Expected CLI failure — printed as a one-line message, never a backtrace.
14
+ class Error < StandardError; end
15
+
16
+ # Shared CLI plumbing — token-endpoint POSTs, profile persistence.
17
+ module Common
18
+ module_function
19
+
20
+ # POST a urlencoded form; return [status, parsed-JSON-or-empty-hash].
21
+ #
22
+ # Connection failures raise CLI::Error with a human message. HTTP error
23
+ # statuses are returned, not raised — device polling needs the error codes.
24
+ def post_form(url, form, timeout: 30)
25
+ uri = URI.parse(url)
26
+ req = Net::HTTP::Post.new(uri)
27
+ req["Content-Type"] = "application/x-www-form-urlencoded"
28
+ req["Accept"] = "application/json"
29
+ req["User-Agent"] = SDK_VERSION
30
+ req.body = URI.encode_www_form(form)
31
+
32
+ resp = begin
33
+ http = Net::HTTP.new(uri.host, uri.port)
34
+ http.use_ssl = uri.scheme == "https"
35
+ http.open_timeout = timeout
36
+ http.read_timeout = timeout
37
+ http.start { |h| h.request(req) }
38
+ rescue Net::OpenTimeout, Net::ReadTimeout, OpenSSL::SSL::SSLError,
39
+ EOFError, SocketError, SystemCallError, IOError => e
40
+ raise Error, "could not reach #{url}: #{e.message}"
41
+ end
42
+
43
+ body = begin
44
+ JSON.parse(resp.body.to_s)
45
+ rescue JSON::ParserError
46
+ nil
47
+ end
48
+ [resp.code.to_i, body.is_a?(Hash) ? body : {}]
49
+ end
50
+
51
+ def token_error_message(status, body)
52
+ detail = CredentialsFile.presence(body["error_description"]) ||
53
+ CredentialsFile.presence(body["error"]) || "HTTP #{status}"
54
+ "sign-in failed: #{detail}"
55
+ end
56
+
57
+ # Store a successful token response as a credentials-file profile.
58
+ #
59
+ # Persists the +tenant+ and +client_id+ extension members — refreshes
60
+ # must use the REAL per-tenant client id, not the +knoxcall-cli+ alias.
61
+ # The write happens UNDER the cross-process file lock so a login racing
62
+ # a concurrent refresh never loses a rotation.
63
+ def persist_login(path:, profile:, base_url:, token_body:, fallback_tenant: nil)
64
+ expires_in = begin
65
+ Float(token_body["expires_in"] || 3600)
66
+ rescue ArgumentError, TypeError
67
+ 3600.0
68
+ end
69
+ record = {
70
+ "tenant" => CredentialsFile.presence(token_body["tenant"]) || fallback_tenant,
71
+ "base_url" => base_url,
72
+ "client_id" => CredentialsFile.presence(token_body["client_id"]) || CLI_CLIENT_ID,
73
+ "refresh_token" => token_body["refresh_token"],
74
+ "access_token" => token_body["access_token"],
75
+ "access_token_expires_at" => CredentialsFile.format_expiry(Time.now + expires_in),
76
+ "scope" => token_body["scope"] || ""
77
+ }
78
+ # The lock file lives next to the target — on a first-ever login the
79
+ # directory does not exist yet, so create it before acquiring.
80
+ FileUtils.mkdir_p(File.dirname(path), mode: 0o700)
81
+ CredentialsFile::Lock.new(path).with_lock do
82
+ CredentialsFile.write_profile(path, profile, record)
83
+ end
84
+ record
85
+ end
86
+
87
+ # Default management base: KNOXCALL_BASE_URL env > (sandbox|production)
88
+ # host. An explicit --base-url beats both (handled by the caller).
89
+ def default_base_url(sandbox)
90
+ env = ENV["KNOXCALL_BASE_URL"]
91
+ return env if env && !env.empty?
92
+ sandbox ? "https://sandbox.#{DEFAULT_CLOUD_HOST}" : DEFAULT_API_BASE
93
+ end
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,94 @@
1
+ module KnoxCall
2
+ module CLI
3
+ # `knoxcall init` — get started wrapping a provider SDK through KnoxCall
4
+ # (sdk-wrapping #17.4; PARITY §13). Mirrors the Node reference (src/cli/init.ts).
5
+ #
6
+ # SAFE BY DESIGN — this does NOT provision a tenant. It works against the
7
+ # tenant you are already signed in to (`knoxcall login`). Two modes:
8
+ #
9
+ # knoxcall init
10
+ # Scaffold mode: confirm who you're signed in as and print a two-step
11
+ # wrap quickstart. No writes.
12
+ #
13
+ # knoxcall init --provider stripe --secret-name wrap-stripe --host api.stripe.com
14
+ # One-shot escrow: move a provider key into KnoxCall custody and print
15
+ # the gateway base_url to point your SDK at. The KEY is read from the
16
+ # KNOXCALL_WRAP_SECRET env var (never a flag) so it stays out of your
17
+ # shell history/argv. Escrow is idempotent-ish server-side (409 on a
18
+ # duplicate name).
19
+ #
20
+ # Tenant provisioning + a fully headless one-shot flow are a deliberate
21
+ # follow-up — a CLI that mints tenants is a bigger, riskier surface.
22
+ module Init
23
+ module_function
24
+
25
+ def run(options)
26
+ # Auth: reuse the stored login. Never provision.
27
+ path = CredentialsFile.resolve_path
28
+ profile = CredentialsFile.resolve_profile(options[:profile])
29
+ if CredentialsFile.read_profile(path, profile).nil?
30
+ raise Error, "not logged in (profile '#{profile}') — run `knoxcall login` first"
31
+ end
32
+
33
+ client_opts = { bootstrap: StoredCredentials.new(path: path, profile: profile) }
34
+ client_opts[:base_url] = options[:base_url] if CredentialsFile.presence(options[:base_url])
35
+ client_opts[:sandbox] = true if options[:sandbox]
36
+ client = Client.new(**client_opts)
37
+
38
+ account = client.account.get || {}
39
+ tenant = CredentialsFile.presence(account["name"]) ||
40
+ CredentialsFile.presence(account["company_name"]) ||
41
+ CredentialsFile.presence(account["slug"]) || "(unknown)"
42
+ puts "Signed in as #{tenant}."
43
+
44
+ # One-shot escrow mode: --provider selects it; the other bits are then required.
45
+ if CredentialsFile.presence(options[:provider])
46
+ return escrow_mode(client, options)
47
+ end
48
+
49
+ scaffold_mode
50
+ end
51
+
52
+ # One-shot escrow: escrow the key (read from KNOXCALL_WRAP_SECRET, never a
53
+ # flag) then mint the gateway base_url. The raw key is never printed.
54
+ def escrow_mode(client, options)
55
+ name = options[:secret_name].to_s.strip
56
+ host = options[:host].to_s.strip.downcase
57
+ value = ENV["KNOXCALL_WRAP_SECRET"]
58
+ raise Error, "--secret-name is required with --provider" if name.empty?
59
+ raise Error, "--host is required with --provider" if host.empty?
60
+ if value.nil? || value.empty?
61
+ raise Error, "set the provider key in the KNOXCALL_WRAP_SECRET env var (not a flag)"
62
+ end
63
+
64
+ client.wrap.escrow(provider: options[:provider], name: name, value: value, hosts: [host])
65
+ res = client.wrap.gateway_url(secret: name, host: host)
66
+ puts ""
67
+ puts "Escrowed '#{name}' for #{host} — your provider key is now in KnoxCall custody."
68
+ puts "Point a base-URL-only SDK at:"
69
+ puts " #{res['base_url']}"
70
+ puts ""
71
+ puts "…or transport-wrap an SDK that takes an injected Faraday connection:"
72
+ puts " knox = KnoxCall::Client.new # your KnoxCall key"
73
+ puts " conn = knox.wrap.faraday_connection(url: \"https://#{host}\")"
74
+ 0
75
+ end
76
+
77
+ # Scaffold mode: print the two-step quickstart, no writes.
78
+ def scaffold_mode
79
+ puts ""
80
+ puts "Wrap a provider SDK through KnoxCall in two steps:"
81
+ puts ""
82
+ puts "1) Move the provider key into custody (key via KNOXCALL_WRAP_SECRET):"
83
+ puts " KNOXCALL_WRAP_SECRET=sk_live_… \\"
84
+ puts " knoxcall init --provider stripe --secret-name wrap-stripe --host api.stripe.com"
85
+ puts ""
86
+ puts "2) Route your SDK through KnoxCall (the key never re-enters your process):"
87
+ puts " knox = KnoxCall::Client.new # your KnoxCall key"
88
+ puts " conn = knox.wrap.faraday_connection(url: \"https://api.stripe.com\")"
89
+ puts " # …or point a base-URL-only SDK at the base_url that step 1 prints."
90
+ 0
91
+ end
92
+ end
93
+ end
94
+ end