riffer-rig 0.4.0 → 0.6.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 (82) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +37 -0
  3. data/README.md +43 -6
  4. data/lib/riffer/rig/cli.rb +51 -47
  5. data/lib/riffer/rig/coding_agent.rb +1 -6
  6. data/lib/riffer/rig/credentials/resolution.rb +16 -0
  7. data/lib/riffer/rig/credentials.rb +151 -60
  8. data/lib/riffer/rig/env.rb +29 -0
  9. data/lib/riffer/rig/events/command_output.rb +27 -0
  10. data/lib/riffer/rig/events/event.rb +30 -0
  11. data/lib/riffer/rig/events/notify.rb +27 -0
  12. data/lib/riffer/rig/events/session_end.rb +24 -0
  13. data/lib/riffer/rig/events/session_start.rb +27 -0
  14. data/lib/riffer/rig/events/skill_activated.rb +24 -0
  15. data/lib/riffer/rig/events/turn_end.rb +32 -0
  16. data/lib/riffer/rig/events.rb +4 -0
  17. data/lib/riffer/rig/extension.rb +27 -0
  18. data/lib/riffer/rig/hosts/base.rb +36 -0
  19. data/lib/riffer/rig/hosts/mirror.rb +60 -0
  20. data/lib/riffer/rig/hosts/null.rb +36 -0
  21. data/lib/riffer/rig/provider_setup/field.rb +24 -0
  22. data/lib/riffer/rig/provider_setup.rb +84 -0
  23. data/lib/riffer/rig/registrar.rb +35 -0
  24. data/lib/riffer/rig/repl.rb +42 -31
  25. data/lib/riffer/rig/runtime/cancel_flag.rb +27 -0
  26. data/lib/riffer/rig/runtime.rb +225 -0
  27. data/lib/riffer/rig/settings/document.rb +28 -0
  28. data/lib/riffer/rig/settings/pricing.rb +11 -1
  29. data/lib/riffer/rig/settings.rb +49 -60
  30. data/lib/riffer/rig/token_tally.rb +1 -21
  31. data/lib/riffer/rig/tools/bash.rb +33 -12
  32. data/lib/riffer/rig/tools/edit.rb +1 -1
  33. data/lib/riffer/rig/tools/read.rb +2 -2
  34. data/lib/riffer/rig/ui/animator.rb +8 -15
  35. data/lib/riffer/rig/ui/cursor.rb +4 -6
  36. data/lib/riffer/rig/ui/renderer.rb +18 -31
  37. data/lib/riffer/rig/ui/smoother.rb +15 -23
  38. data/lib/riffer/rig/ui/theme.rb +4 -6
  39. data/lib/riffer/rig/version.rb +1 -1
  40. data/lib/riffer/rig.rb +26 -0
  41. data/sig/generated/riffer/rig/cli.rbs +27 -19
  42. data/sig/generated/riffer/rig/credentials/resolution.rbs +13 -0
  43. data/sig/generated/riffer/rig/credentials.rbs +71 -41
  44. data/sig/generated/riffer/rig/env.rbs +20 -0
  45. data/sig/generated/riffer/rig/events/command_output.rbs +19 -0
  46. data/sig/generated/riffer/rig/events/event.rbs +20 -0
  47. data/sig/generated/riffer/rig/events/notify.rbs +19 -0
  48. data/sig/generated/riffer/rig/events/session_end.rbs +16 -0
  49. data/sig/generated/riffer/rig/events/session_start.rbs +19 -0
  50. data/sig/generated/riffer/rig/events/skill_activated.rbs +16 -0
  51. data/sig/generated/riffer/rig/events/turn_end.rbs +22 -0
  52. data/sig/generated/riffer/rig/events.rbs +4 -0
  53. data/sig/generated/riffer/rig/extension.rbs +24 -0
  54. data/sig/generated/riffer/rig/hosts/base.rbs +26 -0
  55. data/sig/generated/riffer/rig/hosts/mirror.rbs +41 -0
  56. data/sig/generated/riffer/rig/hosts/null.rbs +26 -0
  57. data/sig/generated/riffer/rig/provider_setup/field.rbs +22 -0
  58. data/sig/generated/riffer/rig/provider_setup.rbs +30 -0
  59. data/sig/generated/riffer/rig/registrar.rbs +25 -0
  60. data/sig/generated/riffer/rig/repl.rbs +17 -16
  61. data/sig/generated/riffer/rig/runtime/cancel_flag.rbs +19 -0
  62. data/sig/generated/riffer/rig/runtime.rbs +120 -0
  63. data/sig/generated/riffer/rig/settings/document.rbs +24 -0
  64. data/sig/generated/riffer/rig/settings/pricing.rbs +4 -1
  65. data/sig/generated/riffer/rig/settings.rbs +28 -49
  66. data/sig/generated/riffer/rig/token_tally.rbs +2 -21
  67. data/sig/generated/riffer/rig/tools/bash.rbs +15 -3
  68. data/sig/generated/riffer/rig/tools/edit.rbs +2 -2
  69. data/sig/generated/riffer/rig/tools/read.rbs +3 -3
  70. data/sig/generated/riffer/rig/ui/animator.rbs +8 -16
  71. data/sig/generated/riffer/rig/ui/cursor.rbs +4 -6
  72. data/sig/generated/riffer/rig/ui/renderer.rbs +13 -35
  73. data/sig/generated/riffer/rig/ui/smoother.rbs +9 -22
  74. data/sig/generated/riffer/rig/ui/theme.rbs +3 -5
  75. data/sig/generated/riffer/rig.rbs +12 -0
  76. data/sig/manual/riffer/rig/cli.rbs +2 -4
  77. data/sig/manual/riffer/rig/credentials.rbs +2 -4
  78. data/sig/manual/riffer/rig/hosts.rbs +5 -0
  79. data/sig/manual/riffer/rig/settings.rbs +2 -4
  80. data/sig/manual/riffer/rig/ui/banner.rbs +2 -4
  81. data/sig/manual/riffer/rig.rbs +11 -0
  82. metadata +45 -3
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e833f2a4dfe0b87598e2be9d585f3cb772562ab39a2dd528684a38dca87f63ab
4
- data.tar.gz: f469b328103a49e52773c0073e02d8c2fbd06d8cf6779daa50c09b49ab2eba8e
3
+ metadata.gz: 5b5a8d6de921c08c1ddb5caa12c5e22e0b3a68f7d26160b6fc732b647209b963
4
+ data.tar.gz: a37ce48ed7e46d9bca9a00db9a46411ecc0187550d58627879e9d97d36789650
5
5
  SHA512:
6
- metadata.gz: 696e8b92a7c1b7a3b71e1f628894e89aed725fbe072dba712d94df277fc5730b06d74def094445b02842d0849409b7900038aedf5d65f29f5416d1450a5b5121
7
- data.tar.gz: 3dc42ac6e625d292462e00119bd7b3c7a8d1f23ad3c4f66763fff96cbfc3328f962dca67bdc4613e884ba81a6e3f61d9af86f1ba1d8bbd16e0f3caa307ca453c
6
+ metadata.gz: b2b6f88978aa64314fb2b420047f97332090c3f8f699caa01564cd1ebf78dd5d862a22dca60e8e0734db647e2506aafbc5bd4de7a1ee1d800ee6a6edf1688b74
7
+ data.tar.gz: 3d6a59d2a2fa892430ddec4682c29fcc934d8568a3827ecf99546ea064b9ec246a1c5dbd5e96a0848328017408d17be91944bd2e84fd1eaf65dcc7707cf74389
data/CHANGELOG.md CHANGED
@@ -1,5 +1,42 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.6.0](https://github.com/bottrall/riffer-rig/compare/v0.5.0...v0.6.0) (2026-09-27)
4
+
5
+
6
+ ### ⚠ BREAKING CHANGES
7
+
8
+ * the flat `{"anthropic": "sk-..."}` auth.json shape is no longer read, with no migration. Stored keys must be re-entered or rewritten as `{"anthropic": {"type": "api_key", "api_key": "sk-..."}}`.
9
+ * Riffer::Rig::Host is removed. A host subclasses Riffer::Rig::Hosts::Base and implements all five methods; the null host is Riffer::Rig::Hosts::Null.
10
+
11
+ ### Features
12
+
13
+ * add the rig event vocabulary and close ([#179](https://github.com/bottrall/riffer-rig/issues/179)) ([35bfa6e](https://github.com/bottrall/riffer-rig/commit/35bfa6e5dc1dc9825fd03843a4e69ec24e675b66))
14
+ * cancel a running turn from any thread ([#186](https://github.com/bottrall/riffer-rig/issues/186)) ([ec68188](https://github.com/bottrall/riffer-rig/commit/ec68188cc7dbcaf0cd56dd41d83cda216661fbe7))
15
+ * **deps:** upgrade riffer to 0.49.0 ([#188](https://github.com/bottrall/riffer-rig/issues/188)) ([821413f](https://github.com/bottrall/riffer-rig/commit/821413f5af65bfcb159ddad532deb3c15779112d))
16
+ * named prompt sections rendered every turn ([#184](https://github.com/bottrall/riffer-rig/issues/184)) ([d190a07](https://github.com/bottrall/riffer-rig/commit/d190a07dfa99764b82bba00745a07e7738c3532a))
17
+ * provider setup table and typed auth.json credentials ([#185](https://github.com/bottrall/riffer-rig/issues/185)) ([0a30c35](https://github.com/bottrall/riffer-rig/commit/0a30c357793716acfabcd1ddd41ea9f98c8a6a8f))
18
+ * store credentials on the Runtime ([#181](https://github.com/bottrall/riffer-rig/issues/181)) ([474a515](https://github.com/bottrall/riffer-rig/commit/474a51586011e413482f0275a7abd3a542e91914))
19
+
20
+
21
+ ### Code Refactoring
22
+
23
+ * make the host contract the abstract Hosts::Base ([#183](https://github.com/bottrall/riffer-rig/issues/183)) ([3fd4f14](https://github.com/bottrall/riffer-rig/commit/3fd4f14a193d546ae06e00776f209a52c0593854))
24
+
25
+ ## [0.5.0](https://github.com/bottrall/riffer-rig/compare/v0.4.0...v0.5.0) (2026-09-19)
26
+
27
+
28
+ ### Features
29
+
30
+ * add ask, max_steps and riffer response ([#178](https://github.com/bottrall/riffer-rig/issues/178)) ([948d753](https://github.com/bottrall/riffer-rig/commit/948d75354a7d0a5a9d35055b7d890dea9e51f1a1))
31
+ * add Runtime with a per-instance agent, base prompt and tool seam ([#160](https://github.com/bottrall/riffer-rig/issues/160)) ([97019ca](https://github.com/bottrall/riffer-rig/commit/97019ca6f80c95bcf22e443652f1d90d453d02de))
32
+ * type IO streams and JSON boundaries, dropping explicit untyped ([#174](https://github.com/bottrall/riffer-rig/issues/174)) ([22407be](https://github.com/bottrall/riffer-rig/commit/22407befc471d1fbbe14188456ee1ce248ddc4c7))
33
+
34
+
35
+ ### Bug Fixes
36
+
37
+ * drain smoother backlog before every animator restart ([#177](https://github.com/bottrall/riffer-rig/issues/177)) ([3126155](https://github.com/bottrall/riffer-rig/commit/3126155fdeba3de2ab2b9905b7e7de55593de834))
38
+ * drop invalid ? optional markers from rbs-inline annotations ([#171](https://github.com/bottrall/riffer-rig/issues/171)) ([60030bf](https://github.com/bottrall/riffer-rig/commit/60030bf7ca930e9d35cbd8c2e3a8ae8ae1692830))
39
+
3
40
  ## [0.4.0](https://github.com/bottrall/riffer-rig/compare/v0.3.0...v0.4.0) (2026-09-13)
4
41
 
5
42
 
data/README.md CHANGED
@@ -28,17 +28,54 @@ riffer
28
28
 
29
29
  ### Authentication
30
30
 
31
- `riffer-rig` talks to Anthropic. Provide your API key in either of two ways:
31
+ `riffer-rig` talks to the provider named by the model's prefix — `anthropic/claude-sonnet-4-6`, the default, means Anthropic. Provide that provider's credentials in either of two ways:
32
32
 
33
- - Set the `ANTHROPIC_API_KEY` environment variable, or
34
- - Run `riffer` and paste your key when prompted on first launch. It is saved to `~/.riffer/auth.json` (file permissions `600`) for subsequent runs.
33
+ - Set the provider's environment variables, or
34
+ - Run `riffer` and paste each missing value when prompted on first launch. Secrets are saved to `~/.riffer/auth.json` (file permissions `600`); plain values such as an endpoint or region are saved to the `providers` block in `~/.riffer/settings.json`.
35
35
 
36
- Create a key at https://console.anthropic.com/settings/keys.
36
+ Each value resolves on its own: environment variable first, then the stored value, then a provider-specific fallback (the AWS shared config for the Bedrock region), then the prompt. Optional values are never prompted for.
37
+
38
+ | Provider | Environment variables | Guide |
39
+ | ---------------- | ------------------------------------------------------------------------- | -------------------------------------------------- |
40
+ | `anthropic` | `ANTHROPIC_API_KEY` | [Anthropic](docs/PROVIDERS.md#anthropic) |
41
+ | `openai` | `OPENAI_API_KEY`; optional `OPENAI_BASE_URL` | [OpenAI](docs/PROVIDERS.md#openai) |
42
+ | `gemini` | `GEMINI_API_KEY` | [Gemini](docs/PROVIDERS.md#gemini) |
43
+ | `openrouter` | `OPENROUTER_API_KEY` | [OpenRouter](docs/PROVIDERS.md#openrouter) |
44
+ | `azure_openai` | `AZURE_OPENAI_ENDPOINT`, `AZURE_OPENAI_API_KEY` | [Azure OpenAI](docs/PROVIDERS.md#azure-openai) |
45
+ | `amazon_bedrock` | `AWS_REGION` or `AWS_DEFAULT_REGION`; optional `AWS_BEARER_TOKEN_BEDROCK` | [Amazon Bedrock](docs/PROVIDERS.md#amazon-bedrock) |
46
+
47
+ `auth.json` holds one typed entry per provider, and a stored secret may be a literal, a `$ENV_VAR` reference or a `!shell command` whose stdout is the secret:
48
+
49
+ ```json
50
+ {
51
+ "anthropic": { "type": "api_key", "api_key": "sk-ant-…" },
52
+ "openai": { "type": "api_key", "api_key": "!security find-generic-password -s openai -w" }
53
+ }
54
+ ```
55
+
56
+ The flat `{"anthropic": "sk-…"}` shape earlier versions wrote is no longer read; paste the key again when prompted. [Providers](docs/PROVIDERS.md) has the full format.
37
57
 
38
58
  ### Configuration
39
59
 
40
- - `RIFFER_MODEL` — override the default model.
41
60
  - `AGENTS.md` — if present, an `AGENTS.md` in the current working directory and/or a global `~/.riffer/AGENTS.md` is loaded as additional context.
61
+ - `~/.riffer/settings.json` — optional user settings. Every key is optional:
62
+
63
+ ```json
64
+ {
65
+ "model": "anthropic/claude-sonnet-4-6",
66
+ "reasoning": "low",
67
+ "models": {
68
+ "anthropic/claude-sonnet-4-6": {
69
+ "input": 3.0,
70
+ "output": 15.0,
71
+ "cache_write": 3.75,
72
+ "cache_read": 0.3
73
+ }
74
+ }
75
+ }
76
+ ```
77
+
78
+ `models` holds pricing in USD per million tokens; a model without an entry shows no cost. `reasoning` is translated to the provider's own parameter — Anthropic accepts `low`, `medium`, `high`, `xhigh` and `max`; OpenAI and OpenRouter accept `low`, `medium`, `high` and `xhigh`. Omitting it, or supplying an unrecognised value, leaves the model's default reasoning behaviour unchanged.
42
79
 
43
80
  ## Development
44
81
 
@@ -62,7 +99,7 @@ PR titles are [conventional commits](https://www.conventionalcommits.org/) and a
62
99
 
63
100
  [release-please](https://github.com/googleapis/release-please) keeps a release PR open that bumps `lib/riffer/rig/version.rb` and writes `CHANGELOG.md`. Merging that PR tags `vX.Y.Z`, creates the GitHub Release and publishes the gem to RubyGems.org through Trusted Publishing; nothing is pushed by hand.
64
101
 
65
- riffer is pinned to one minor (`~> 0.45.0`) because its 0.x minors may break. When Dependabot opens the riffer bump, retitle it `feat(deps):` or `fix(deps):` before merging so it releases and appears in the changelog.
102
+ riffer is pinned to one minor (`~> 0.49.0`) because its 0.x minors may break. When Dependabot opens the riffer bump, retitle it `feat(deps):` or `fix(deps):` before merging so it releases and appears in the changelog.
66
103
 
67
104
  ## Contributing
68
105
 
@@ -5,27 +5,22 @@ require 'io/console'
5
5
  module Riffer::Rig::CLI
6
6
  extend self
7
7
 
8
- PROVIDER_URLS = {
9
- 'anthropic' => 'https://console.anthropic.com/settings/keys',
10
- 'openai' => 'https://platform.openai.com/api-keys',
11
- 'gemini' => 'https://aistudio.google.com/app/apikey',
12
- 'openrouter' => 'https://openrouter.ai/keys'
13
- }.freeze #: Hash[String, String]
14
-
15
- # @rbs output: untyped
16
- # @rbs input: untyped
8
+ # @rbs output: IO
9
+ # @rbs input: IO
10
+ # @rbs model: String
11
+ # @rbs env: Riffer::Rig::Env
17
12
  # @rbs return: Integer
18
- def start(output: $stdout, input: $stdin)
19
- theme = Riffer::Rig::UI::Theme.for(output)
13
+ def start(output: $stdout, input: $stdin, model: Riffer::Rig::Settings.model, env: Riffer::Rig::Env.new)
14
+ theme = Riffer::Rig::UI::Theme.for(output, env:)
20
15
 
21
- model = Riffer::Rig::Settings.model
22
16
  provider = Riffer::Rig::Settings.provider_for(model)
23
17
 
24
- api_key = (provider && Riffer::Rig::Credentials.api_key_for(provider)) ||
25
- onboard(provider, theme, output:, input:)
26
- return 1 if api_key.nil?
18
+ if provider
19
+ values = credentials_for(provider, theme, output:, input:, env:)
20
+ return 1 if values.nil?
27
21
 
28
- configure_provider(provider, api_key)
22
+ Riffer::Rig::Credentials.apply(provider, values)
23
+ end
29
24
 
30
25
  agent = Riffer::Rig::CodingAgent.new
31
26
  animator = Riffer::Rig::UI::Animator.new(io: output, theme:)
@@ -41,51 +36,60 @@ module Riffer::Rig::CLI
41
36
 
42
37
  private
43
38
 
44
- # @rbs provider: String?
45
- # @rbs api_key: String
46
- # @rbs return: void
47
- def configure_provider(provider, api_key)
48
- case provider
49
- when 'anthropic' then Riffer.configure { |c| c.anthropic.api_key = api_key }
50
- when 'openai' then Riffer.configure { |c| c.openai.api_key = api_key }
51
- when 'gemini' then Riffer.configure { |c| c.gemini.api_key = api_key }
52
- when 'openrouter' then Riffer.configure { |c| c.openrouter.api_key = api_key }
53
- end
39
+ # @rbs provider: String
40
+ # @rbs theme: Riffer::Rig::UI::Theme
41
+ # @rbs output: IO
42
+ # @rbs input: IO
43
+ # @rbs env: Riffer::Rig::Env
44
+ # @rbs return: Hash[Symbol, String]?
45
+ def credentials_for(provider, theme, output:, input:, env:)
46
+ resolution = Riffer::Rig::Credentials.resolve(provider, host: Riffer::Rig::Hosts::Null.new, env:)
47
+ return resolution.values if resolution.missing.empty?
48
+
49
+ onboard(provider, resolution, theme, output:, input:)
54
50
  end
55
51
 
56
- # @rbs provider: String?
52
+ # @rbs provider: String
53
+ # @rbs resolution: Riffer::Rig::Credentials::Resolution
57
54
  # @rbs theme: Riffer::Rig::UI::Theme
58
- # @rbs output: untyped
59
- # @rbs input: untyped
60
- # @rbs return: String?
61
- def onboard(provider, theme, output:, input:)
62
- url = (provider && PROVIDER_URLS[provider]) || 'your provider'
63
- name = provider ? provider.capitalize : 'provider'
55
+ # @rbs output: IO
56
+ # @rbs input: IO
57
+ # @rbs return: Hash[Symbol, String]?
58
+ def onboard(provider, resolution, theme, output:, input:)
59
+ setup = Riffer::Rig::ProviderSetup.for(provider)
60
+ missing = setup.fields.select { |field| resolution.missing.include?(field.name) }
64
61
 
65
62
  output.puts(theme.cyan('♪ welcome to riffer-rig ♪'))
66
- output.puts(theme.grey("No #{name} API key found. Create one at #{url}"))
67
- output.print("#{theme.pink('›')} Paste your #{name} API key #{theme.grey('(hidden)')}: ")
63
+ output.puts(theme.grey("No #{provider} credentials found. Create them at #{setup.url || 'your provider'}"))
68
64
 
69
- key = read_secret(input).to_s.strip
70
- output.puts
71
-
72
- if key.empty?
73
- output.puts(theme.grey("No key entered. Set #{env_var_for(provider)} or re-run riffer to try again."))
65
+ answers = missing.to_h { |field| [field.name, prompt(provider, field, theme, output:, input:)] }
66
+ if answers.values.any?(&:empty?)
67
+ env_vars = missing.flat_map(&:env).join(', ')
68
+ output.puts(theme.grey("Nothing entered. Set #{env_vars} or re-run riffer to try again."))
74
69
  return nil
75
70
  end
76
71
 
77
- Riffer::Rig::Credentials.save_api_key(provider, key) if provider
78
- output.puts(theme.grey("Saved to #{Riffer::Rig::Credentials::PATH} (permissions 600)."))
79
- key
72
+ Riffer::Rig::Credentials.store(provider, answers)
73
+ output.puts(theme.grey("Saved under #{File.dirname(Riffer::Rig::Credentials::PATH)} (auth.json permissions 600)."))
74
+ resolution.values.merge(answers)
80
75
  end
81
76
 
82
- # @rbs provider: String?
77
+ # @rbs provider: String
78
+ # @rbs field: Riffer::Rig::ProviderSetup::Field
79
+ # @rbs theme: Riffer::Rig::UI::Theme
80
+ # @rbs output: IO
81
+ # @rbs input: IO
83
82
  # @rbs return: String
84
- def env_var_for(provider)
85
- (provider && Riffer::Rig::Credentials::PROVIDER_ENV_VARS[provider]) || 'the appropriate API key env var'
83
+ def prompt(provider, field, theme, output:, input:)
84
+ hint = field.secret ? " #{theme.grey('(hidden)')}" : ''
85
+ output.print("#{theme.pink('›')} Paste your #{provider} #{field.name}#{hint}: ")
86
+
87
+ answer = (field.secret ? read_secret(input) : input.gets).to_s.strip
88
+ output.puts
89
+ answer
86
90
  end
87
91
 
88
- # @rbs input: untyped
92
+ # @rbs input: IO
89
93
  # @rbs return: String?
90
94
  def read_secret(input)
91
95
  return input.noecho(&:gets) if input.respond_to?(:noecho) && input.tty?
@@ -12,12 +12,7 @@ class Riffer::Rig::CodingAgent < Riffer::Agent
12
12
  model Riffer::Rig::Settings.model
13
13
  model_options Riffer::Rig::Settings.model_options
14
14
 
15
- uses_tools [
16
- Riffer::Rig::Tools::Read,
17
- Riffer::Rig::Tools::Write,
18
- Riffer::Rig::Tools::Edit,
19
- Riffer::Rig::Tools::Bash
20
- ]
15
+ uses_tools [Riffer::Rig::Tools::Read, Riffer::Rig::Tools::Write, Riffer::Rig::Tools::Edit, Riffer::Rig::Tools::Bash]
21
16
 
22
17
  skills do
23
18
  backend(->(_ctx) { Riffer::Skills::FilesystemBackend.new(GLOBAL_SKILLS_DIR, PROJECT_SKILLS_DIR.call) })
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Riffer::Rig::Credentials::Resolution
4
+ # @dynamic values, missing
5
+ attr_reader :values #: Hash[Symbol, String]
6
+ attr_reader :missing #: Array[Symbol]
7
+
8
+ # @rbs values: Hash[Symbol, String]
9
+ # @rbs missing: Array[Symbol]
10
+ # @rbs return: void
11
+ def initialize(values:, missing:)
12
+ @values = values.freeze
13
+ @missing = missing.freeze
14
+ freeze
15
+ end
16
+ end
@@ -2,88 +2,179 @@
2
2
 
3
3
  require 'json'
4
4
  require 'fileutils'
5
+ require 'open3'
5
6
 
6
- # Reads and writes API keys for each supported provider in
7
- # <tt>~/.riffer/auth.json</tt>.
8
- #
9
- # Keys are stored under the provider name, e.g.:
10
- #
11
- # {
12
- # "anthropic": "sk-ant-...",
13
- # "openai": "sk-...",
14
- # "gemini": "...",
15
- # "openrouter": "sk-or-..."
16
- # }
17
- #
18
- # The environment variable checked per provider:
19
- #
20
- # anthropic → ANTHROPIC_API_KEY
21
- # openai → OPENAI_API_KEY
22
- # gemini → GEMINI_API_KEY
23
- # openrouter → OPENROUTER_API_KEY
24
- #
25
7
  module Riffer::Rig::Credentials
26
8
  extend self
27
9
 
28
10
  PATH = File.expand_path('~/.riffer/auth.json') #: String
29
11
 
30
- PROVIDER_ENV_VARS = {
31
- 'anthropic' => 'ANTHROPIC_API_KEY',
32
- 'openai' => 'OPENAI_API_KEY',
33
- 'gemini' => 'GEMINI_API_KEY',
34
- 'openrouter' => 'OPENROUTER_API_KEY'
35
- }.freeze #: Hash[String, String]
36
-
37
- # Returns the API key for +provider+, checking the environment variable first,
38
- # then the stored file. Returns +nil+ if no key is available.
39
- #
40
- # @rbs provider: String?
41
- # @rbs path: String
42
- # @rbs return: String?
43
- def api_key_for(provider, path: PATH)
44
- env_var = provider && PROVIDER_ENV_VARS[provider]
45
- key = env_var && ENV.fetch(env_var, nil).then { |v| v unless v.nil? || v.strip.empty? }
46
- key || key_from_file(provider, path)
12
+ ENTRY_TYPE = 'api_key'
13
+
14
+ # @rbs identifier: String | Symbol
15
+ # @rbs host: Riffer::Rig::Hosts::Base
16
+ # @rbs setup: Riffer::Rig::ProviderSetup
17
+ # @rbs env: Riffer::Rig::Env
18
+ # @rbs auth_path: String
19
+ # @rbs settings_path: String
20
+ # @rbs return: Resolution
21
+ def resolve(
22
+ identifier,
23
+ host:,
24
+ setup: Riffer::Rig::ProviderSetup.for(identifier),
25
+ env: Riffer::Rig::Env.new,
26
+ auth_path: PATH,
27
+ settings_path: Riffer::Rig::Settings::PATH
28
+ )
29
+ secrets = stored_secrets(identifier, auth_path)
30
+ plain = Riffer::Rig::Settings.provider_fields(identifier.to_s, path: settings_path)
31
+ found = setup.fields.to_h do |field|
32
+ [field.name, env.value_for(field) || stored_value(field, secrets, plain, env) || field.fallback&.call]
33
+ end.compact
34
+ required = setup.fields.select(&:required)
35
+ answers = ask_for(required.reject { |field| found.key?(field.name) }, identifier, host)
36
+ store(identifier, answers, setup:, auth_path:, settings_path:)
37
+
38
+ values = found.merge(answers)
39
+ Resolution.new(values:, missing: required.map(&:name) - values.keys)
47
40
  end
48
41
 
49
- # Saves an API key for +provider+ to the auth file with 0600 permissions.
50
- #
51
- # @rbs provider: String
52
- # @rbs key: String
53
- # @rbs path: String
54
- # @rbs return: String
55
- def save_api_key(provider, key, path: PATH)
56
- FileUtils.mkdir_p(File.dirname(path), mode: 0o700)
57
- data = read(path).tap { |h| h[provider] = key }
58
- File.open(path, File::WRONLY | File::CREAT | File::TRUNC, 0o600) do |file|
59
- file.write(JSON.pretty_generate(data))
42
+ # @rbs identifier: String | Symbol
43
+ # @rbs values: Hash[Symbol, String]
44
+ # @rbs config: Riffer::Config
45
+ # @rbs return: void
46
+ def apply(identifier, values, config: Riffer.config)
47
+ return unless Riffer::Rig::ProviderSetup[identifier]
48
+
49
+ provider_config = config.public_send(identifier)
50
+ values.each { |name, value| provider_config.public_send(:"#{name}=", value) }
51
+ end
52
+
53
+ # @rbs identifier: String | Symbol
54
+ # @rbs values: Hash[Symbol, String]
55
+ # @rbs setup: Riffer::Rig::ProviderSetup
56
+ # @rbs auth_path: String
57
+ # @rbs settings_path: String
58
+ # @rbs return: void
59
+ def store(
60
+ identifier,
61
+ values,
62
+ setup: Riffer::Rig::ProviderSetup.for(identifier),
63
+ auth_path: PATH,
64
+ settings_path: Riffer::Rig::Settings::PATH
65
+ )
66
+ secret_names = setup.fields.select(&:secret).map { |field| field.name.to_s }
67
+ fields = values.transform_keys(&:to_s)
68
+ secrets = fields.slice(*secret_names)
69
+ plain = fields.except(*secret_names)
70
+
71
+ unless secrets.empty?
72
+ entries = read_auth(auth_path).merge(identifier.to_s => secrets) do |_identifier, stored, given|
73
+ stored.merge(given)
74
+ end
75
+ write_auth(auth_path, entries)
60
76
  end
61
- key
77
+ Riffer::Rig::Settings.store_provider(identifier.to_s, plain, path: settings_path) unless plain.empty?
78
+ end
79
+
80
+ # @rbs identifier: String | Symbol
81
+ # @rbs auth_path: String
82
+ # @rbs settings_path: String
83
+ # @rbs return: void
84
+ def remove(identifier, auth_path: PATH, settings_path: Riffer::Rig::Settings::PATH)
85
+ entries = read_auth(auth_path)
86
+ write_auth(auth_path, entries.except(identifier.to_s)) if entries.key?(identifier.to_s)
87
+ Riffer::Rig::Settings.remove_provider(identifier.to_s, path: settings_path)
88
+ end
89
+
90
+ # @rbs identifier: String | Symbol
91
+ # @rbs setup: Riffer::Rig::ProviderSetup
92
+ # @rbs env: Riffer::Rig::Env
93
+ # @rbs auth_path: String
94
+ # @rbs return: :env | :stored | :chain | :missing
95
+ def status(identifier, setup: Riffer::Rig::ProviderSetup.for(identifier), env: Riffer::Rig::Env.new, auth_path: PATH)
96
+ secret_fields = setup.fields.select(&:secret)
97
+ return :env if secret_fields.any? { |field| env.value_for(field) }
98
+
99
+ stored = stored_secrets(identifier, auth_path)
100
+ return :stored if secret_fields.any? { |field| stored.key?(field.name.to_s) }
101
+
102
+ setup.chain ? :chain : :missing
62
103
  end
63
104
 
64
105
  private
65
106
 
66
- # @rbs provider: String?
67
- # @rbs path: String
107
+ # @rbs fields: Array[Riffer::Rig::ProviderSetup::Field]
108
+ # @rbs identifier: String | Symbol
109
+ # @rbs host: Riffer::Rig::Hosts::Base
110
+ # @rbs return: Hash[Symbol, String]
111
+ def ask_for(fields, identifier, host)
112
+ fields.to_h do |field|
113
+ [field.name, presence(host.ask("#{identifier} #{field.name}", secret: field.secret))]
114
+ end.compact
115
+ end
116
+
117
+ # @rbs field: Riffer::Rig::ProviderSetup::Field
118
+ # @rbs secrets: Hash[String, String]
119
+ # @rbs plain: Hash[String, String]
120
+ # @rbs env: Riffer::Rig::Env
121
+ # @rbs return: String?
122
+ def stored_value(field, secrets, plain, env)
123
+ field.secret ? expand(secrets[field.name.to_s], env) : plain[field.name.to_s]
124
+ end
125
+
126
+ # @rbs value: String?
127
+ # @rbs env: Riffer::Rig::Env
128
+ # @rbs return: String?
129
+ def expand(value, env)
130
+ return nil if value.nil?
131
+ return env[value.delete_prefix('$')] if value.start_with?('$')
132
+ return presence(run(value.delete_prefix('!'))) if value.start_with?('!')
133
+
134
+ value
135
+ end
136
+
137
+ # @rbs command: String
138
+ # @rbs return: String?
139
+ def run(command)
140
+ stdout, status = Open3.capture2(command)
141
+ stdout if status.success?
142
+ end
143
+
144
+ # @rbs value: String?
68
145
  # @rbs return: String?
69
- def key_from_file(provider, path)
70
- key = read(path)[provider]
71
- return nil unless key.is_a?(String)
72
- return key unless key.strip.empty?
146
+ def presence(value)
147
+ stripped = value.to_s.strip
148
+ stripped unless stripped.empty?
149
+ end
73
150
 
74
- nil
151
+ # @rbs identifier: String | Symbol
152
+ # @rbs path: String
153
+ # @rbs return: Hash[String, String]
154
+ def stored_secrets(identifier, path)
155
+ read_auth(path)[identifier.to_s] || {}
75
156
  end
76
157
 
77
- # The parsed auth file, or an empty hash when the file is absent or
78
- # malformed.
79
- #
80
158
  # @rbs path: String
81
- # @rbs return: Hash[String, untyped]
82
- def read(path)
159
+ # @rbs return: Hash[String, Hash[String, String]]
160
+ def read_auth(path)
83
161
  return {} unless File.file?(path)
84
162
 
85
163
  JSON.parse(File.read(path))
164
+ .select { |_identifier, entry| entry.is_a?(Hash) && entry['type'] == ENTRY_TYPE }
165
+ .transform_values { |entry| entry.except('type').select { |_name, value| value.is_a?(String) } }
86
166
  rescue JSON::ParserError
87
167
  {}
88
168
  end
169
+
170
+ # @rbs path: String
171
+ # @rbs entries: Hash[String, Hash[String, String]]
172
+ # @rbs return: void
173
+ def write_auth(path, entries)
174
+ FileUtils.mkdir_p(File.dirname(path), mode: 0o700)
175
+ data = entries.transform_values { |fields| { 'type' => ENTRY_TYPE }.merge(fields) }
176
+ File.open(path, File::WRONLY | File::CREAT | File::TRUNC, 0o600) do |file|
177
+ file.write(JSON.pretty_generate(data))
178
+ end
179
+ end
89
180
  end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Riffer::Rig::Env
4
+ # @rbs @source: Hash[String, String]
5
+
6
+ # @dynamic no_color
7
+ attr_reader :no_color #: bool
8
+
9
+ # @rbs source: Hash[String, String]
10
+ # @rbs return: void
11
+ def initialize(source = ENV.to_h)
12
+ @source = source.freeze
13
+ @no_color = @source.key?('NO_COLOR')
14
+ freeze
15
+ end
16
+
17
+ # @rbs name: String
18
+ # @rbs return: String?
19
+ def [](name)
20
+ stripped = @source[name].to_s.strip
21
+ stripped unless stripped.empty?
22
+ end
23
+
24
+ # @rbs field: Riffer::Rig::ProviderSetup::Field
25
+ # @rbs return: String?
26
+ def value_for(field)
27
+ field.env.filter_map { |name| self[name] }.first
28
+ end
29
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Riffer::Rig::Events::CommandOutput < Riffer::Rig::Events::Event
4
+ # @dynamic command, text
5
+ attr_reader :command #: String
6
+ attr_reader :text #: String
7
+
8
+ # @rbs command: String
9
+ # @rbs text: String
10
+ # @rbs return: void
11
+ def initialize(command, text)
12
+ super()
13
+ @command = command
14
+ @text = text
15
+ freeze
16
+ end
17
+
18
+ # @rbs return: Symbol
19
+ def type
20
+ :command_output
21
+ end
22
+
23
+ # @rbs return: Hash[Symbol, untyped]
24
+ def to_h
25
+ { type: type, command: command, text: text }
26
+ end
27
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Riffer::Rig::Events::Event
4
+ # @rbs return: Symbol
5
+ def type
6
+ raise NotImplementedError, "#{self.class.name} must define type"
7
+ end
8
+
9
+ # @rbs return: Hash[Symbol, untyped]
10
+ def to_h
11
+ raise NotImplementedError, "#{self.class.name} must define to_h"
12
+ end
13
+
14
+ # @rbs other: untyped
15
+ # @rbs return: bool
16
+ def ==(other)
17
+ other.class == self.class && other.to_h == to_h
18
+ end
19
+
20
+ # @rbs return: Integer
21
+ def hash
22
+ [self.class, to_h].hash
23
+ end
24
+
25
+ # @rbs other: untyped
26
+ # @rbs return: bool
27
+ def eql?(other)
28
+ self == other
29
+ end
30
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Riffer::Rig::Events::Notify < Riffer::Rig::Events::Event
4
+ # @dynamic message, level
5
+ attr_reader :message #: String?
6
+ attr_reader :level #: Symbol
7
+
8
+ # @rbs message: String?
9
+ # @rbs level: Symbol
10
+ # @rbs return: void
11
+ def initialize(message, level)
12
+ super()
13
+ @message = message
14
+ @level = level
15
+ freeze
16
+ end
17
+
18
+ # @rbs return: Symbol
19
+ def type
20
+ :notify
21
+ end
22
+
23
+ # @rbs return: Hash[Symbol, untyped]
24
+ def to_h
25
+ { type: type, message: message, level: level }
26
+ end
27
+ end