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,377 @@
1
+ require "optparse"
2
+
3
+ require "knoxcall"
4
+ require "knoxcall/cli/common"
5
+ require "knoxcall/cli/login"
6
+ require "knoxcall/cli/logout"
7
+ require "knoxcall/cli/whoami"
8
+ require "knoxcall/cli/init"
9
+ require "knoxcall/cli/ai"
10
+ require "knoxcall/cli/ai_control"
11
+
12
+ module KnoxCall
13
+ # KnoxCall CLI — `knoxcall login` / `logout` / `whoami` / `init` / `ai`.
14
+ #
15
+ # Shipped as the gem executable (`exe/knoxcall`); `KnoxCall::CLI.run(argv)`
16
+ # is the testable entry point. Credentials are stored in the cross-SDK
17
+ # ~/.knoxcall/credentials.json file and picked up automatically by every
18
+ # KnoxCall SDK (auto-detect slot 2). The command surface, messages, and exit
19
+ # codes mirror the python reference implementation (PARITY §13).
20
+ module CLI
21
+ PROGRAM = "knoxcall"
22
+ COMMANDS = %w[login logout whoami init ai].freeze
23
+ # `ai` is the only command with a sub-command of its own.
24
+ #
25
+ # AIGW-162. `exchange` is the data-plane door and needs no login; the rest
26
+ # are the control plane and act as the signed-in tenant. Both live under
27
+ # `ai` because they are one surface to a user, and the golden path crosses
28
+ # between them: create-agent -> mint -> a real call.
29
+ #
30
+ # Every one of these takes FLAGS ONLY, no positionals — four of the five SDK
31
+ # CLIs hand-roll their parser and reject positionals outright (only python
32
+ # gets them free from argparse), so an id as a positional would be a surface
33
+ # that is the same in all five except in shape.
34
+ AI_COMMANDS = %w[exchange gateways agents create-agent mint usage].freeze
35
+ AI_COMMAND_SUMMARIES = {
36
+ "exchange" => "exchange a CI OIDC token for a capability token (no login needed)",
37
+ "gateways" => "list AI gateways",
38
+ "agents" => "list a gateway's agents",
39
+ "create-agent" => "create an agent with its upstream credential",
40
+ "mint" => "mint a capability token (shown once)",
41
+ "usage" => "cost + token usage by model"
42
+ }.freeze
43
+ USAGE = "usage: #{PROGRAM} [-h] {#{COMMANDS.join(',')}} ..."
44
+ DESCRIPTION =
45
+ "KnoxCall command-line interface — sign in once, every SDK on this machine picks it up."
46
+ COMMAND_SUMMARIES = {
47
+ "login" => "sign in with your browser and store credentials locally",
48
+ "logout" => "revoke and remove stored credentials",
49
+ "whoami" => "show the signed-in tenant",
50
+ "init" => "get started wrapping a provider SDK (escrow a key)",
51
+ "ai" => "AI gateway operations"
52
+ }.freeze
53
+ PROFILE_HELP = "credentials profile name (default: KNOXCALL_PROFILE or 'default')"
54
+
55
+ module_function
56
+
57
+ # Run the CLI: 0 on success, 1 on expected failure/interrupt ("error: …" /
58
+ # "aborted" on stderr, never a backtrace), 2 on usage errors.
59
+ def run(argv = ARGV)
60
+ parsed = parse(Array(argv).map(&:to_s))
61
+ return parsed if parsed.is_a?(Integer) # help printed (0) or usage error (2)
62
+
63
+ command, options = parsed
64
+ begin
65
+ execute(command, options)
66
+ rescue Error, KnoxCall::Error => e
67
+ warn "error: #{e.message}"
68
+ 1
69
+ rescue Interrupt
70
+ warn "aborted"
71
+ 1
72
+ end
73
+ end
74
+
75
+ def execute(command, options)
76
+ case command
77
+ when "login" then Login.run(options)
78
+ when "logout" then Logout.run(options)
79
+ when "whoami" then Whoami.run(options)
80
+ when "init" then Init.run(options)
81
+ when "ai" then execute_ai(options)
82
+ end
83
+ end
84
+
85
+ # `ai` fans out to its own sub-commands. `exchange` is the data-plane door
86
+ # (no login); the other five are the control plane and act as the
87
+ # signed-in tenant.
88
+ def execute_ai(options)
89
+ case options[:ai_command]
90
+ when "exchange" then Ai.run(options)
91
+ when "gateways" then AiControl.gateways(options)
92
+ when "agents" then AiControl.agents(options)
93
+ when "create-agent" then AiControl.create_agent(options)
94
+ when "mint" then AiControl.mint(options)
95
+ when "usage" then AiControl.usage(options)
96
+ end
97
+ end
98
+
99
+ # Parse argv into [command, options]. Help and usage errors are handled
100
+ # here: help prints to stdout and returns 0; a usage error prints to
101
+ # stderr and returns 2 (matching the python reference's argparse).
102
+ def parse(argv)
103
+ if argv.empty?
104
+ warn USAGE
105
+ warn "#{PROGRAM}: error: a command is required (choose from #{COMMANDS.join(', ')})"
106
+ return 2
107
+ end
108
+ if %w[-h --help].include?(argv.first)
109
+ puts root_help
110
+ return 0
111
+ end
112
+ command = argv.first
113
+ unless COMMANDS.include?(command)
114
+ warn USAGE
115
+ warn "#{PROGRAM}: error: invalid choice: '#{command}' (choose from #{COMMANDS.join(', ')})"
116
+ return 2
117
+ end
118
+
119
+ options = {}
120
+ rest_argv = argv[1..]
121
+ ai_command = nil
122
+
123
+ # `ai` carries a sub-command. Consume it here, then fall through to the
124
+ # same OptionParser path with argv advanced past it — one parser, not two.
125
+ if command == "ai"
126
+ sub = rest_argv.first
127
+ if sub.nil?
128
+ warn ai_usage
129
+ warn "#{PROGRAM} ai: error: a sub-command is required (choose from #{AI_COMMANDS.join(', ')})"
130
+ return 2
131
+ end
132
+ if %w[-h --help].include?(sub)
133
+ puts ai_help
134
+ return 0
135
+ end
136
+ unless AI_COMMANDS.include?(sub)
137
+ warn ai_usage
138
+ warn "#{PROGRAM} ai: error: invalid choice: '#{sub}' (choose from #{AI_COMMANDS.join(', ')})"
139
+ return 2
140
+ end
141
+ ai_command = sub
142
+ options[:ai_command] = sub
143
+ rest_argv = rest_argv[1..]
144
+ end
145
+
146
+ label = ai_command ? "#{command} #{ai_command}" : command
147
+ help_requested = false
148
+ parser = build_parser(command, options, ai_command)
149
+ parser.on("-h", "--help", "show this help message and exit") { help_requested = true }
150
+ begin
151
+ rest = parser.parse(rest_argv)
152
+ rescue OptionParser::ParseError => e
153
+ warn parser.banner
154
+ warn "#{PROGRAM} #{label}: error: #{e.message}"
155
+ return 2
156
+ end
157
+ if help_requested
158
+ puts parser
159
+ return 0
160
+ end
161
+ unless rest.empty?
162
+ warn parser.banner
163
+ warn "#{PROGRAM} #{label}: error: unrecognized arguments: #{rest.join(' ')}"
164
+ return 2
165
+ end
166
+ [command, options]
167
+ end
168
+
169
+ def ai_usage
170
+ "usage: #{PROGRAM} ai [-h] {#{AI_COMMANDS.join(',')}} ..."
171
+ end
172
+
173
+ def ai_help
174
+ <<~HELP
175
+ #{ai_usage}
176
+
177
+ AI-gateway operations.
178
+
179
+ From a tenant with nothing in it to a real streamed call, in two commands:
180
+
181
+ export ANTHROPIC_API_KEY=sk-ant-...
182
+ knoxcall ai create-agent --name copilot --slug copilot \\
183
+ --provider anthropic --secret-from-env ANTHROPIC_API_KEY
184
+ knoxcall ai mint --agent <id>
185
+
186
+ sub-commands:
187
+ #{AI_COMMANDS.map { |c| format(' %-14s %s', c, AI_COMMAND_SUMMARIES[c]) }.join("\n")}
188
+
189
+ options:
190
+ -h, --help show this help message and exit
191
+ HELP
192
+ end
193
+
194
+ def root_help
195
+ <<~HELP
196
+ #{USAGE}
197
+
198
+ #{DESCRIPTION}
199
+
200
+ commands:
201
+ #{COMMANDS.map { |c| format(' %-8s %s', c, COMMAND_SUMMARIES[c]) }.join("\n")}
202
+
203
+ options:
204
+ -h, --help show this help message and exit
205
+ HELP
206
+ end
207
+
208
+ def build_parser(command, options, ai_command = nil)
209
+ OptionParser.new do |o|
210
+ o.banner = "usage: #{PROGRAM} #{command} [options]"
211
+ o.summary_width = 18
212
+ case command
213
+ when "login"
214
+ o.on("--tenant SLUG", "tenant slug hint for the sign-in page") do |v|
215
+ options[:tenant] = v
216
+ end
217
+ o.on("--base-url URL",
218
+ "management API base URL (default https://api.knoxcall.com, or KNOXCALL_BASE_URL)") do |v|
219
+ options[:base_url] = v
220
+ end
221
+ o.on("--sandbox", "log in against the sandbox environment") do
222
+ options[:sandbox] = true
223
+ end
224
+ o.on("--profile NAME", PROFILE_HELP) { |v| options[:profile] = v }
225
+ o.on("--device", "use the device-code flow (headless/SSH machines)") do
226
+ options[:device] = true
227
+ end
228
+ o.on("--no-browser", "never open a browser (implies the device-code flow)") do
229
+ options[:no_browser] = true
230
+ end
231
+ when "logout", "whoami"
232
+ o.on("--profile NAME", PROFILE_HELP) { |v| options[:profile] = v }
233
+ when "init"
234
+ o.on("--profile NAME", PROFILE_HELP) { |v| options[:profile] = v }
235
+ o.on("--base-url URL",
236
+ "management API base URL (default https://api.knoxcall.com)") do |v|
237
+ options[:base_url] = v
238
+ end
239
+ o.on("--sandbox", "operate against the sandbox environment") do
240
+ options[:sandbox] = true
241
+ end
242
+ o.on("--provider PROVIDER", "provider to escrow a key for (e.g. stripe); enables escrow mode") do |v|
243
+ options[:provider] = v
244
+ end
245
+ o.on("--secret-name NAME", "name for the escrowed credential (required with --provider)") do |v|
246
+ options[:secret_name] = v
247
+ end
248
+ o.on("--host HOST", "upstream host to pin the credential to (required with --provider)") do |v|
249
+ options[:host] = v
250
+ end
251
+ when "ai"
252
+ o.banner = "usage: #{PROGRAM} ai #{ai_command} [options]"
253
+ build_ai_parser(o, options, ai_command)
254
+ end
255
+ end
256
+ end
257
+
258
+ # The `ai` flag table is keyed by SUB-command, not by `ai`.
259
+ #
260
+ # A single shared table would accept `ai exchange --period 30d` and silently
261
+ # ignore it — the opposite of what every other command here does with an
262
+ # unknown flag (usage error, exit 2). It also has to be per-sub-command for
263
+ # `--secret-value` to be REJECTED on create-agent: an unknown flag is only
264
+ # unknown if the table it is checked against is the one for that command.
265
+ def build_ai_parser(opt, options, ai_command)
266
+ case ai_command
267
+ when "exchange"
268
+ opt.on("--tenant SLUG", "tenant slug; the data-plane host is https://{tenant}.knoxcall.com") do |v|
269
+ options[:tenant] = v
270
+ end
271
+ opt.on("--sandbox", "use the Test data space (sandbox-{tenant}.knoxcall.com)") do
272
+ options[:sandbox] = true
273
+ end
274
+ opt.on("--base-url URL", "full data-plane origin; overrides --tenant") do |v|
275
+ options[:base_url] = v
276
+ end
277
+ # Assigned even when empty: the key's PRESENCE is what says the
278
+ # caller asked for a resource, and an empty one is a server refusal
279
+ # rather than "no resource".
280
+ opt.on("--resource URI",
281
+ "RFC 8707 resource indicator (an MCP server's `resource`); narrows the token to that one MCP server") do |v|
282
+ options[:resource] = v
283
+ end
284
+ opt.on("--audience AUDIENCE", "defaults to knoxcall:gateway") do |v|
285
+ options[:audience] = v
286
+ end
287
+ when "gateways"
288
+ ai_common_options(opt, options)
289
+ when "agents"
290
+ opt.on("--gateway ID", "gateway id") { |v| options[:gateway] = v }
291
+ ai_common_options(opt, options)
292
+ when "create-agent"
293
+ # Printed by `--help` only — OptionParser#banner, which is what a usage
294
+ # error echoes, stays the single usage line.
295
+ ai_create_agent_preamble(opt)
296
+ opt.on("--slug SLUG", "url slug; the agent is served at /v1/ai/{slug}") do |v|
297
+ options[:slug] = v
298
+ end
299
+ opt.on("--provider PROVIDER",
300
+ "provider id (anthropic, openai, bedrock, …); catalog is server-side") do |v|
301
+ options[:provider] = v
302
+ end
303
+ opt.on("--secret ID", "id of an existing KnoxCall secret holding the key") do |v|
304
+ options[:secret] = v
305
+ end
306
+ # The key is read from the NAMED ENVIRONMENT VARIABLE, never from a
307
+ # flag value: an argv value lands in shell history, ps output and the
308
+ # CI log line that echoes the command.
309
+ opt.on("--secret-from-env VAR",
310
+ "env var holding the key; escrows it, reusing a same-named secret") do |v|
311
+ options[:secret_from_env] = v
312
+ end
313
+ opt.on("--name NAME", "display name (defaults to --slug)") { |v| options[:name] = v }
314
+ opt.on("--gateway ID", "gateway id or slug to create under") { |v| options[:gateway] = v }
315
+ opt.on("--model MODEL", "default model (required for openai-compatible)") do |v|
316
+ options[:model] = v
317
+ end
318
+ opt.on("--upstream URL",
319
+ "upstream base URL; required for azure-openai, ollama, " \
320
+ "bedrock and openai-compatible") do |v|
321
+ options[:upstream] = v
322
+ end
323
+ ai_common_options(opt, options)
324
+ when "mint"
325
+ opt.separator ""
326
+ opt.separator "Mint a capability token for an agent. The plaintext is returned ONCE and is"
327
+ opt.separator "the only thing on stdout, so it can be captured:"
328
+ opt.separator " TOKEN=\"$(knoxcall ai mint --agent ag_123)\""
329
+ opt.separator ""
330
+ opt.separator "options:"
331
+ opt.on("--agent ID", "agent id") { |v| options[:agent] = v }
332
+ opt.on("--kind KIND", "agent | read | tool | oneshot (default agent)") { |v| options[:kind] = v }
333
+ opt.on("--name NAME", "label for the token") { |v| options[:name] = v }
334
+ ai_common_options(opt, options)
335
+ when "usage"
336
+ opt.on("--period PERIOD", "7d | 30d | 90d (default 30d)") { |v| options[:period] = v }
337
+ opt.on("--agent ID", "scope to one agent") { |v| options[:agent] = v }
338
+ ai_common_options(opt, options)
339
+ end
340
+ end
341
+
342
+ # The three rules `create-agent` exists to enforce, printed by `--help`.
343
+ # Separators land in the summary only, so a usage error still echoes the
344
+ # one-line banner rather than nine lines of prose.
345
+ def ai_create_agent_preamble(opt)
346
+ opt.separator ""
347
+ opt.separator "Create an agent wired to a provider credential, and print the command that"
348
+ opt.separator "follows. Works on a tenant with nothing in it: with no --gateway it uses your"
349
+ opt.separator "only gateway, or creates one when you have none. With several it refuses and"
350
+ opt.separator "lists them rather than picking one for you."
351
+ opt.separator ""
352
+ opt.separator "The provider key is read from the environment named by --secret-from-env,"
353
+ opt.separator "never from a flag — an argv value lands in shell history, ps output and the"
354
+ opt.separator "CI log. There is deliberately no --secret-value."
355
+ opt.separator ""
356
+ opt.separator "--provider and a credential are both required: the API accepts an agent with"
357
+ opt.separator "neither and stores one whose first data-plane call 502s."
358
+ opt.separator ""
359
+ opt.separator "Only the agent id goes to stdout, so it can be captured:"
360
+ opt.separator " AGENT=\"$(knoxcall ai create-agent --slug copilot --provider anthropic \\"
361
+ opt.separator " --secret-from-env ANTHROPIC_API_KEY)\""
362
+ opt.separator ""
363
+ opt.separator "options:"
364
+ end
365
+
366
+ # Accepted by every control-plane sub-command: they select WHICH tenant and
367
+ # WHICH stored login is acting. `exchange` takes none of them — it needs no
368
+ # login at all.
369
+ def ai_common_options(opt, options)
370
+ opt.on("--profile NAME", PROFILE_HELP) { |v| options[:profile] = v }
371
+ opt.on("--base-url URL", "management API base URL (default https://api.knoxcall.com)") do |v|
372
+ options[:base_url] = v
373
+ end
374
+ opt.on("--sandbox", "operate against the Test data space") { options[:sandbox] = true }
375
+ end
376
+ end
377
+ end