riffer-rig 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 3f73a840a836511a6fae8b5c7d6e74ec91fbcb538512db64ad08e9fe75968941
4
+ data.tar.gz: c8d503c5bc6a363be2b4d940441436c865a760b88919ada174672e2422a20904
5
+ SHA512:
6
+ metadata.gz: 43cdadee5554656753dd5d2e46bae4456df480844d30e7119d4e45b5647395236cb1acc5209aa9fbfec5da501888f04221d21ddbd49925183edef56daca4b3f7
7
+ data.tar.gz: d05268fab46137782a580f18a134105c48aae93b50d1249936c6b76fe7ba056bf3f08bd23ba6d15a4a75fb80b02dfefd5aad98fdac54fbb51f433aa4e41a844d
data/CHANGELOG.md ADDED
@@ -0,0 +1,23 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 (2026-09-05)
4
+
5
+
6
+ ### ⚠ BREAKING CHANGES
7
+
8
+ * the gem, executable, namespace, config directory and environment variable are all renamed; nothing reads the old names.
9
+
10
+ ### Features
11
+
12
+ * add prompt caching, token tally, and settings ([#7](https://github.com/bottrall/riffer-rig/issues/7)) ([bb02a95](https://github.com/bottrall/riffer-rig/commit/bb02a954c2fffa13ad8e564513c82681ab02d1e4))
13
+ * add skills support to CLI, REPL, and banner ([#5](https://github.com/bottrall/riffer-rig/issues/5)) ([5ac1dd1](https://github.com/bottrall/riffer-rig/commit/5ac1dd186c3f05adb342e1dead6102f2544d3b2b))
14
+ * configurable reasoning levels ([#34](https://github.com/bottrall/riffer-rig/issues/34)) ([a9604f3](https://github.com/bottrall/riffer-rig/commit/a9604f3994fc2cef5888c653fd1f07b82ac662f8))
15
+ * multi-provider support (Anthropic, OpenAI, Gemini, OpenRouter) ([#32](https://github.com/bottrall/riffer-rig/issues/32)) ([30b5ccf](https://github.com/bottrall/riffer-rig/commit/30b5ccf92ea43182953f1b5da089fe9e4550c9c9))
16
+ * rename to riffer-rig ([#95](https://github.com/bottrall/riffer-rig/issues/95)) ([d036d57](https://github.com/bottrall/riffer-rig/commit/d036d57f6fd48c3171383708caa0f22a34f7343d))
17
+
18
+
19
+ ### Bug Fixes
20
+
21
+ * point riffer dependency to janeapp/riffer ([#2](https://github.com/bottrall/riffer-rig/issues/2)) ([0060283](https://github.com/bottrall/riffer-rig/commit/00602835baf9759d4a012b4506605f2b6ed7c250))
22
+ * rename Riffer::Boolean to Riffer::Params::Boolean ([#3](https://github.com/bottrall/riffer-rig/issues/3)) ([8996d01](https://github.com/bottrall/riffer-rig/commit/8996d0114a69b4db212cc9b0cbd2ec796f35a779))
23
+ * require /skill: prefix to activate skills ([#37](https://github.com/bottrall/riffer-rig/issues/37)) ([ac6313d](https://github.com/bottrall/riffer-rig/commit/ac6313d7622e3483271cacef8b7bfe60ed33503a))
data/README.md ADDED
@@ -0,0 +1,79 @@
1
+ # riffer-rig
2
+
3
+ A dead-simple terminal coding agent built on [riffer](https://github.com/janeapp/riffer).
4
+
5
+ `riffer-rig` is an interactive terminal coding agent with read, write, edit, and bash tools — point it at your project and chat with it from the command line.
6
+
7
+ ## Requirements
8
+
9
+ - Ruby >= 4.0
10
+
11
+ ## Installation
12
+
13
+ Install the gem:
14
+
15
+ ```bash
16
+ gem install riffer-rig
17
+ ```
18
+
19
+ This provides a `riffer` executable.
20
+
21
+ ## Usage
22
+
23
+ Run the agent from any project directory:
24
+
25
+ ```bash
26
+ riffer
27
+ ```
28
+
29
+ ### Authentication
30
+
31
+ `riffer-rig` talks to Anthropic. Provide your API key in either of two ways:
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.
35
+
36
+ Create a key at https://console.anthropic.com/settings/keys.
37
+
38
+ ### Configuration
39
+
40
+ - `RIFFER_MODEL` — override the default model.
41
+ - `AGENTS.md` — if present, an `AGENTS.md` in the current working directory and/or a global `~/.riffer/AGENTS.md` is loaded as additional context.
42
+
43
+ ## Development
44
+
45
+ Every project chore is a script in `bin/`. The Rakefile behind them is an implementation detail; you never need to call rake directly.
46
+
47
+ | Script | What it does |
48
+ | --- | --- |
49
+ | `bin/setup` | Install dependencies on a fresh checkout |
50
+ | `bin/test` | Run the test suite. Pass files and/or Minitest flags: `bin/test test/foo_test.rb -n /pattern/` |
51
+ | `bin/lint` | Run RuboCop. Arguments are forwarded, e.g. `bin/lint -a` |
52
+ | `bin/typecheck` | Check `sig/generated` is current, then type-check with Steep |
53
+ | `bin/rbs` | Regenerate `sig/generated` from the inline annotations in `lib/` |
54
+ | `bin/rbs-watch` | Regenerate `sig/generated` whenever `lib/` changes |
55
+ | `bin/ci` | Run everything CI runs, serially. Use before pushing |
56
+
57
+ ## Releasing
58
+
59
+ PR titles are [conventional commits](https://www.conventionalcommits.org/) and are linted in CI: `feat:` bumps the minor version, `fix:` bumps the patch, and `feat!:` marks a breaking change (also a minor bump while we are on 0.x). `chore:`, `docs:`, `ci:`, `refactor:` and `test:` never release. Squash-merging makes the title the commit on `main`.
60
+
61
+ [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.
62
+
63
+ 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.
64
+
65
+ ## Contributing
66
+
67
+ 1. Fork the repository and create your branch: `git checkout -b feature/foo`
68
+ 2. Run the tests and linters locally.
69
+ 3. Submit a pull request with a clear description of the change.
70
+
71
+ Please follow the [Code of Conduct](CODE_OF_CONDUCT.md).
72
+
73
+ ## License
74
+
75
+ Licensed under the MIT License. See [`LICENSE.txt`](LICENSE.txt) for details.
76
+
77
+ ## Maintainer
78
+
79
+ - Jake Bottrall - https://github.com/bottrall
data/exe/riffer ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "bundler/setup" if File.exist?(File.expand_path("../Gemfile", __dir__))
5
+
6
+ require_relative "../lib/riffer/rig"
7
+
8
+ exit(Riffer::Rig::CLI.start)
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'io/console'
4
+
5
+ module Riffer::Rig::CLI
6
+ extend self
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
14
+
15
+ def start(output: $stdout, input: $stdin)
16
+ theme = Riffer::Rig::UI::Theme.for(output)
17
+
18
+ model = Riffer::Rig::Settings.model
19
+ provider = Riffer::Rig::Settings.provider_for(model)
20
+
21
+ api_key = (provider && Riffer::Rig::Credentials.api_key_for(provider)) ||
22
+ onboard(provider, theme, output:, input:)
23
+ return 1 if api_key.nil?
24
+
25
+ configure_provider(provider, api_key)
26
+
27
+ agent = Riffer::Rig::CodingAgent.new
28
+ animator = Riffer::Rig::UI::Animator.new(io: output, theme:)
29
+
30
+ reveal_banner(theme, animator, model)
31
+
32
+ tally = Riffer::Rig::TokenTally.new(pricing: Riffer::Rig::Settings.pricing_for(model))
33
+ renderer = Riffer::Rig::UI::Renderer.new(io: output, theme:, tally:)
34
+ Riffer::Rig::REPL.new(agent:, renderer:, animator:, theme:, input:, output:).run
35
+ 0
36
+ end
37
+
38
+ private
39
+
40
+ def configure_provider(provider, api_key)
41
+ case provider
42
+ when 'anthropic' then Riffer.configure { |c| c.anthropic.api_key = api_key }
43
+ when 'openai' then Riffer.configure { |c| c.openai.api_key = api_key }
44
+ when 'gemini' then Riffer.configure { |c| c.gemini.api_key = api_key }
45
+ when 'openrouter' then Riffer.configure { |c| c.openrouter.api_key = api_key }
46
+ end
47
+ end
48
+
49
+ def onboard(provider, theme, output:, input:)
50
+ url = (provider && PROVIDER_URLS[provider]) || 'your provider'
51
+ name = provider ? provider.capitalize : 'provider'
52
+
53
+ output.puts(theme.cyan('♪ welcome to riffer-rig ♪'))
54
+ output.puts(theme.grey("No #{name} API key found. Create one at #{url}"))
55
+ output.print("#{theme.pink('›')} Paste your #{name} API key #{theme.grey('(hidden)')}: ")
56
+
57
+ key = read_secret(input).to_s.strip
58
+ output.puts
59
+
60
+ if key.empty?
61
+ output.puts(theme.grey("No key entered. Set #{env_var_for(provider)} or re-run riffer to try again."))
62
+ return nil
63
+ end
64
+
65
+ Riffer::Rig::Credentials.save_api_key(provider, key) if provider
66
+ output.puts(theme.grey("Saved to #{Riffer::Rig::Credentials::PATH} (permissions 600)."))
67
+ key
68
+ end
69
+
70
+ def env_var_for(provider)
71
+ (provider && Riffer::Rig::Credentials::PROVIDER_ENV_VARS[provider]) || 'the appropriate API key env var'
72
+ end
73
+
74
+ def read_secret(input)
75
+ return input.noecho(&:gets) if input.respond_to?(:noecho) && input.tty?
76
+
77
+ input.gets
78
+ end
79
+
80
+ def reveal_banner(theme, animator, model)
81
+ loaded = [Riffer::Rig::CodingAgent::GLOBAL_AGENTS_FILE, File.join(Dir.pwd, 'AGENTS.md')].select { |path| File.file?(path) }
82
+ context = loaded.empty? ? 'none' : loaded.join(', ')
83
+
84
+ glints = Riffer::Rig::UI::Riffy::GLINT_COLS + [nil]
85
+ frames = glints.map do |glint_col|
86
+ Riffer::Rig::UI::Banner.lines(theme, model: model, cwd: Dir.pwd, context: context, skills: count_skills, version: Riffer::Rig::VERSION, glint_col: glint_col)
87
+ end
88
+ animator.reveal(frames)
89
+ end
90
+
91
+ def count_skills
92
+ dirs = [Riffer::Rig::CodingAgent::GLOBAL_SKILLS_DIR, Riffer::Rig::CodingAgent::PROJECT_SKILLS_DIR.call]
93
+ backend = Riffer::Skills::FilesystemBackend.new(*dirs)
94
+ count = backend.list_skills.length
95
+ count.zero? ? 'none' : count.to_s
96
+ rescue StandardError
97
+ 'none'
98
+ end
99
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'date'
4
+
5
+ class Riffer::Rig::CodingAgent < Riffer::Agent
6
+ GLOBAL_AGENTS_FILE = File.expand_path('~/.riffer/AGENTS.md')
7
+ GLOBAL_SKILLS_DIR = File.expand_path('~/.riffer/skills')
8
+ PROJECT_SKILLS_DIR = -> { File.join(Dir.pwd, '.skills') }
9
+
10
+ model Riffer::Rig::Settings.model
11
+ model_options Riffer::Rig::Settings.model_options
12
+
13
+ uses_tools [
14
+ Riffer::Rig::Tools::Read,
15
+ Riffer::Rig::Tools::Write,
16
+ Riffer::Rig::Tools::Edit,
17
+ Riffer::Rig::Tools::Bash
18
+ ]
19
+
20
+ skills do
21
+ backend(->(_ctx) { Riffer::Skills::FilesystemBackend.new(GLOBAL_SKILLS_DIR, PROJECT_SKILLS_DIR.call) })
22
+ end
23
+
24
+ max_steps nil
25
+
26
+ instructions(lambda do
27
+ context_files = [GLOBAL_AGENTS_FILE, File.join(Dir.pwd, 'AGENTS.md')]
28
+ .select { |path| File.file?(path) }
29
+ .map { |path| "<project_instructions path=\"#{path}\">\n#{File.read(path)}\n</project_instructions>" }
30
+
31
+ project_context =
32
+ if context_files.empty?
33
+ ''
34
+ else
35
+ "\n\n<project_context>\n#{context_files.join("\n\n")}\n</project_context>"
36
+ end
37
+
38
+ <<~PROMPT.chomp + project_context + "\n\nCurrent date: #{Date.today}\nCurrent working directory: #{Dir.pwd}"
39
+ You are an expert coding assistant running inside riffer-rig, a terminal coding agent. You help the user by reading files, running shell commands, editing code, and writing new files.
40
+
41
+ Available tools:
42
+ - read: read a file's contents
43
+ - write: create or overwrite a file
44
+ - edit: replace an exact string in a file
45
+ - bash: run a shell command (use it for ls, rg/grep, find, tests, git, etc.)
46
+
47
+ Guidelines:
48
+ - Be concise and direct in your responses.
49
+ - Show file paths clearly when working with files.
50
+ - Use bash for file exploration (ls, rg, find) rather than guessing.
51
+ - Prefer editing existing files over creating new ones.
52
+ PROMPT
53
+ end)
54
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'fileutils'
5
+
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
+ module Riffer::Rig::Credentials
26
+ extend self
27
+
28
+ PATH = File.expand_path('~/.riffer/auth.json')
29
+
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
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
+ def api_key_for(provider, path: PATH)
40
+ env_var = PROVIDER_ENV_VARS[provider]
41
+ key = env_var && ENV.fetch(env_var, nil).then { |v| v unless v.nil? || v.strip.empty? }
42
+ key || key_from_file(provider, path)
43
+ end
44
+
45
+ # Saves an API key for +provider+ to the auth file with 0600 permissions.
46
+ def save_api_key(provider, key, path: PATH)
47
+ FileUtils.mkdir_p(File.dirname(path), mode: 0o700)
48
+ data = read(path).tap { |h| h[provider] = key }
49
+ File.open(path, File::WRONLY | File::CREAT | File::TRUNC, 0o600) do |file|
50
+ file.write(JSON.pretty_generate(data))
51
+ end
52
+ key
53
+ end
54
+
55
+ private
56
+
57
+ def key_from_file(provider, path)
58
+ key = read(path)[provider]
59
+ key unless key.nil? || key.strip.empty?
60
+ end
61
+
62
+ def read(path)
63
+ return {} unless File.file?(path)
64
+
65
+ JSON.parse(File.read(path))
66
+ rescue JSON::ParserError
67
+ {}
68
+ end
69
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Riffer::Rig::REPL
4
+ EXIT_COMMANDS = ['/exit', '/quit'].freeze
5
+ SKILL_COMMAND = %r{\A/skill:([a-z0-9]+(?:-[a-z0-9]+)*)(?:\s+(.*))?\z}m
6
+
7
+ def initialize(agent:, renderer:, input: $stdin, output: $stdout, theme: Riffer::Rig::UI::Theme.for(output), animator: Riffer::Rig::UI::Animator.new(io: output, theme:))
8
+ @agent = agent
9
+ @renderer = renderer
10
+ @animator = animator
11
+ @theme = theme
12
+ @input = input
13
+ @output = output
14
+ @agent.session.on_message { |message| @renderer.render_tool_result(message) }
15
+ end
16
+
17
+ def run
18
+ loop do
19
+ @output.print("\n#{@theme.pink('›')} ")
20
+ line = @input.gets
21
+ break if line.nil?
22
+
23
+ prompt = line.strip
24
+ next if prompt.empty?
25
+ break if EXIT_COMMANDS.include?(prompt)
26
+
27
+ match = SKILL_COMMAND.match(prompt)
28
+
29
+ if match
30
+ run_skill_command(match[1], match[2].to_s.strip)
31
+ else
32
+ run_turn(prompt)
33
+ end
34
+ end
35
+
36
+ @output.puts("\n#{@theme.grey('see you on the next riff.')}")
37
+ end
38
+
39
+ private
40
+
41
+ def run_turn(prompt)
42
+ @animator.start_thinking
43
+ @agent.stream(prompt).each do |event|
44
+ @animator.stop_thinking
45
+ @renderer.render(event)
46
+ end
47
+ @output.puts
48
+ rescue StandardError => e
49
+ @output.puts("\nError: #{e.message}")
50
+ ensure
51
+ @animator.stop_thinking
52
+ end
53
+
54
+ def run_skill_command(name, args)
55
+ block = activate_skill(name)
56
+ return if block.nil?
57
+
58
+ run_turn([block, args].reject(&:empty?).join("\n\n"))
59
+ end
60
+
61
+ def activate_skill(name)
62
+ skills = @agent.context.skills
63
+
64
+ unless skills
65
+ @output.puts(@theme.grey('No skills configured.'))
66
+ return
67
+ end
68
+
69
+ # TODO: read the body without mutating activation state once riffer exposes
70
+ # a non-mutating Context#read. `activate` marks the skill model-activated as
71
+ # a side effect, which drops it from the model's catalog after manual use.
72
+ body = skills.activate(name)
73
+ @output.puts(@theme.magenta("✦ skill: #{name}"))
74
+ skill_block(name, body)
75
+ rescue Riffer::ArgumentError
76
+ @output.puts(@theme.red("Unknown skill: #{name}"))
77
+ nil
78
+ rescue StandardError => e
79
+ @output.puts(@theme.red("Error activating skill: #{e.message}"))
80
+ nil
81
+ end
82
+
83
+ def skill_block(name, body)
84
+ "<skill name=\"#{name}\">\n#{body}\n</skill>"
85
+ end
86
+ end
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'fileutils'
5
+
6
+ # Reads and writes the riffer-rig user settings file at
7
+ # <tt>~/.riffer/settings.json</tt>.
8
+ #
9
+ # Example file:
10
+ #
11
+ # {
12
+ # "model": "anthropic/claude-sonnet-4-6",
13
+ # "reasoning": "low",
14
+ # "models": {
15
+ # "anthropic/claude-sonnet-4-6": {
16
+ # "input": 3.0,
17
+ # "output": 15.0,
18
+ # "cache_write": 3.75,
19
+ # "cache_read": 0.3
20
+ # }
21
+ # }
22
+ # }
23
+ #
24
+ # All keys are optional. Missing pricing means cost display is suppressed.
25
+ # <tt>"reasoning"</tt> is translated to the appropriate provider-specific
26
+ # parameter on each API call; accepted values depend on the provider:
27
+ #
28
+ # - Anthropic: <tt>"low"</tt>, <tt>"medium"</tt>, <tt>"high"</tt>,
29
+ # <tt>"xhigh"</tt>, <tt>"max"</tt>
30
+ # - OpenAI / OpenRouter: <tt>"low"</tt>, <tt>"medium"</tt>, <tt>"high"</tt>,
31
+ # <tt>"xhigh"</tt>
32
+ #
33
+ # Omitting the key (or supplying an unrecognised value) leaves the model's
34
+ # default reasoning behaviour unchanged.
35
+ #
36
+ module Riffer::Rig::Settings
37
+ extend self
38
+
39
+ PATH = File.expand_path('~/.riffer/settings.json')
40
+ DEFAULT_MODEL = 'anthropic/claude-sonnet-4-6'
41
+
42
+ REASONING_LEVELS_BY_PROVIDER = {
43
+ 'anthropic' => %w[low medium high xhigh max].freeze,
44
+ 'openai' => %w[low medium high xhigh].freeze,
45
+ 'openrouter' => %w[low medium high xhigh].freeze
46
+ }.freeze
47
+
48
+ # Returns the configured model string, or +DEFAULT_MODEL+ if not set.
49
+ def model(path: PATH)
50
+ read(path).fetch('model', DEFAULT_MODEL)
51
+ end
52
+
53
+ # Returns model options for the configured model and reasoning level, ready
54
+ # to pass directly to the Riffer agent's +model_options+.
55
+ def model_options(path: PATH)
56
+ provider = provider_for(model(path:))
57
+ base_options(provider).merge(reasoning_options(reasoning_for(path:, provider:), provider))
58
+ end
59
+
60
+ # Returns the provider prefix for +model_string+, e.g. <tt>"anthropic"</tt>
61
+ # for <tt>"anthropic/claude-sonnet-4-6"</tt>. Returns +nil+ if the model
62
+ # string contains no slash.
63
+ def provider_for(model_string)
64
+ model_string.split('/', 2).first if model_string.include?('/')
65
+ end
66
+
67
+ # Returns the pricing hash for +model+, or +nil+ if not configured.
68
+ #
69
+ # The returned hash has symbol keys +:input+, +:output+, +:cache_write+,
70
+ # +:cache_read+ (all Float, USD per million tokens).
71
+ def pricing_for(model, path: PATH)
72
+ entry = read(path).dig('models', model)
73
+ return nil unless entry
74
+
75
+ {
76
+ input: entry.fetch('input', 0).to_f,
77
+ output: entry.fetch('output', 0).to_f,
78
+ cache_write: entry.fetch('cache_write', 0).to_f,
79
+ cache_read: entry.fetch('cache_read', 0).to_f
80
+ }
81
+ end
82
+
83
+ private
84
+
85
+ def base_options(provider)
86
+ return { cache_control: { type: :ephemeral } } if provider == 'anthropic'
87
+
88
+ {}
89
+ end
90
+
91
+ def reasoning_for(path: PATH, provider: nil)
92
+ level = read(path)['reasoning']
93
+ valid_levels = (provider && REASONING_LEVELS_BY_PROVIDER[provider]) || []
94
+ valid_levels.include?(level) ? level : nil
95
+ end
96
+
97
+ # Maps a reasoning level to the provider-specific model option hash expected
98
+ # by Riffer. Returns an empty hash when +level+ is +nil+.
99
+ def reasoning_options(level, provider)
100
+ return {} unless level
101
+
102
+ case provider
103
+ when 'anthropic'
104
+ { output_config: { effort: level } }
105
+ when 'openai', 'openrouter'
106
+ { reasoning: level }
107
+ else
108
+ {}
109
+ end
110
+ end
111
+
112
+ def read(path)
113
+ return {} unless File.file?(path)
114
+
115
+ JSON.parse(File.read(path))
116
+ rescue JSON::ParserError
117
+ {}
118
+ end
119
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Accumulates token usage across multiple turns in a session and computes an
4
+ # estimated cost based on a pricing hash sourced from +Riffer::Rig::Settings+.
5
+ #
6
+ # Instantiate once per REPL session, call +add+ after each turn, then read
7
+ # +total_tokens+ and +estimated_cost+ for display.
8
+ #
9
+ # pricing = Riffer::Rig::Settings.pricing_for('anthropic/claude-sonnet-4-6')
10
+ # tally = Riffer::Rig::TokenTally.new(pricing: pricing)
11
+ # tally.add(usage)
12
+ # tally.estimated_cost # => 0.000042 (USD), or nil if pricing is nil
13
+ #
14
+ class Riffer::Rig::TokenTally
15
+ attr_reader :input_tokens, :output_tokens, :cache_write_tokens, :cache_read_tokens
16
+
17
+ def initialize(pricing: nil)
18
+ @pricing = pricing
19
+ @input_tokens = 0
20
+ @output_tokens = 0
21
+ @cache_write_tokens = 0
22
+ @cache_read_tokens = 0
23
+ end
24
+
25
+ # Accumulates token counts from a +Riffer::Providers::TokenUsage+ object.
26
+ #
27
+ def add(usage)
28
+ @input_tokens += usage.input_tokens
29
+ @output_tokens += usage.output_tokens
30
+ @cache_write_tokens += usage.cache_write_tokens || 0
31
+ @cache_read_tokens += usage.cache_read_tokens || 0
32
+ end
33
+
34
+ # Returns the total token count across all categories.
35
+ #
36
+ def total_tokens
37
+ @input_tokens + @output_tokens + @cache_write_tokens + @cache_read_tokens
38
+ end
39
+
40
+ # Returns +true+ if any tokens have been counted.
41
+ #
42
+ def any?
43
+ total_tokens.positive?
44
+ end
45
+
46
+ # Returns the estimated cost in USD, or +nil+ if no pricing was provided.
47
+ #
48
+ def estimated_cost
49
+ return nil unless @pricing
50
+
51
+ (
52
+ (@input_tokens * @pricing[:input]) +
53
+ (@output_tokens * @pricing[:output]) +
54
+ (@cache_write_tokens * @pricing[:cache_write]) +
55
+ (@cache_read_tokens * @pricing[:cache_read])
56
+ ) / 1_000_000.0
57
+ end
58
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'open3'
4
+
5
+ class Riffer::Rig::Tools::Bash < Riffer::Tool
6
+ identifier 'bash'
7
+ description 'Run a shell command in the working directory and return its combined stdout/stderr and exit status. ' \
8
+ 'Use this for listing, searching (rg/grep/find), running tests, git, etc.'
9
+
10
+ timeout 600
11
+
12
+ DEFAULT_TIMEOUT_MS = 120_000
13
+ MAX_OUTPUT_BYTES = 30_000
14
+
15
+ params do
16
+ required :command, String, description: 'The shell command to run'
17
+ optional :timeout_ms, Integer, description: 'Kill the command after this many milliseconds', default: DEFAULT_TIMEOUT_MS
18
+ end
19
+
20
+ def call(context:, command:, timeout_ms: DEFAULT_TIMEOUT_MS)
21
+ output, status = run(command, timeout_ms / 1000.0)
22
+ output = truncate(output.rstrip)
23
+
24
+ return error("Command exited with status #{status}\n#{output}", type: :command_failed) unless status.zero?
25
+
26
+ text(output.empty? ? '(no output)' : output)
27
+ end
28
+
29
+ private
30
+
31
+ def run(command, timeout_seconds)
32
+ stdin, stdout_and_stderr, wait_thread = Open3.popen2e(command, chdir: Dir.pwd, pgroup: true)
33
+ stdin.close
34
+
35
+ unless wait_thread.join(timeout_seconds)
36
+ kill_group(wait_thread.pid)
37
+ output = stdout_and_stderr.read
38
+ stdout_and_stderr.close
39
+ return ["#{output}\n[timed out after #{timeout_seconds.round}s]", 124]
40
+ end
41
+
42
+ output = stdout_and_stderr.read
43
+ stdout_and_stderr.close
44
+ [output, wait_thread.value.exitstatus || 1]
45
+ end
46
+
47
+ def kill_group(pid)
48
+ Process.kill('TERM', -Process.getpgid(pid))
49
+ rescue Errno::ESRCH, Errno::EPERM
50
+ nil
51
+ end
52
+
53
+ def truncate(output)
54
+ return output if output.bytesize <= MAX_OUTPUT_BYTES
55
+
56
+ "#{output.byteslice(0, MAX_OUTPUT_BYTES)}\n[output truncated to #{MAX_OUTPUT_BYTES} bytes]"
57
+ end
58
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Riffer::Rig::Tools::Edit < Riffer::Tool
4
+ identifier 'edit'
5
+ description 'Replace an exact string in a file. By default old_string must occur exactly once; ' \
6
+ 'set replace_all to replace every occurrence.'
7
+
8
+ params do
9
+ required :path, String, description: 'Path to the file (absolute, or relative to the working directory)'
10
+ required :old_string, String, description: 'The exact text to replace'
11
+ required :new_string, String, description: 'The text to replace it with'
12
+ optional :replace_all, Riffer::Params::Boolean, description: 'Replace all occurrences instead of requiring a unique match', default: false
13
+ end
14
+
15
+ def call(context:, path:, old_string:, new_string:, replace_all: false)
16
+ resolved = File.expand_path(path, Dir.pwd)
17
+ return error("File not found: #{path}", type: :not_found) unless File.file?(resolved)
18
+
19
+ content = File.read(resolved)
20
+ occurrences = content.scan(old_string).length
21
+
22
+ return error("old_string not found in #{path}", type: :not_found) if occurrences.zero?
23
+ return error("old_string is not unique in #{path} (#{occurrences} matches). Pass replace_all or add more context.", type: :ambiguous_match) if occurrences > 1 && !replace_all
24
+
25
+ # Block form so backslash sequences in new_string (\0, \1, \\) are inserted
26
+ # literally instead of being interpreted as regexp backreferences.
27
+ updated = replace_all ? content.gsub(old_string) { new_string } : content.sub(old_string) { new_string }
28
+ File.write(resolved, updated)
29
+
30
+ text("Edited #{path} (#{replace_all ? occurrences : 1} replacement#{'s' if replace_all && occurrences > 1})")
31
+ end
32
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Riffer::Rig::Tools::Read < Riffer::Tool
4
+ identifier 'read'
5
+ description 'Read a file from the filesystem. Returns its contents with line numbers. ' \
6
+ 'Use offset/limit to read a slice of a large file.'
7
+
8
+ DEFAULT_LIMIT = 2000
9
+
10
+ params do
11
+ required :path, String, description: 'Path to the file (absolute, or relative to the working directory)'
12
+ optional :offset, Integer, description: '1-based line number to start reading from'
13
+ optional :limit, Integer, description: 'Maximum number of lines to read', default: DEFAULT_LIMIT
14
+ end
15
+
16
+ def call(context:, path:, offset: nil, limit: DEFAULT_LIMIT)
17
+ resolved = File.expand_path(path, Dir.pwd)
18
+ return error("File not found: #{path}", type: :not_found) unless File.file?(resolved)
19
+ return error('offset must be >= 1', type: :invalid_argument) if offset && offset < 1
20
+
21
+ start = offset ? offset - 1 : 0
22
+ lines = File.readlines(resolved, chomp: true)
23
+ slice = lines[start, limit] || []
24
+
25
+ numbered = slice.each_with_index.map do |line, i|
26
+ format("%<lineno>6d\t%<text>s", lineno: start + i + 1, text: line)
27
+ end
28
+
29
+ text(numbered.join("\n"))
30
+ end
31
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+
5
+ class Riffer::Rig::Tools::Write < Riffer::Tool
6
+ identifier 'write'
7
+ description 'Write content to a file, creating it (and any parent directories) or overwriting it.'
8
+
9
+ params do
10
+ required :path, String, description: 'Path to the file (absolute, or relative to the working directory)'
11
+ required :content, String, description: 'The full content to write to the file'
12
+ end
13
+
14
+ def call(context:, path:, content:)
15
+ resolved = File.expand_path(path, Dir.pwd)
16
+ FileUtils.mkdir_p(File.dirname(resolved))
17
+ File.write(resolved, content)
18
+
19
+ text("Wrote #{content.bytesize} bytes to #{path}")
20
+ end
21
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Animated output is gated behind an interactive, colour-enabled TTY, so piped
4
+ # or tested runs stay silent and escape-free.
5
+ class Riffer::Rig::UI::Animator
6
+ REVEAL_FRAME_SECONDS = 0.05
7
+ SPINNER_FRAME_SECONDS = 0.12
8
+
9
+ def initialize(io: $stdout, theme: Riffer::Rig::UI::Theme.for(io))
10
+ @io = io
11
+ @theme = theme
12
+ @thread = nil
13
+ end
14
+
15
+ # When not on a TTY, prints the final frame once instead of animating.
16
+ def reveal(frames)
17
+ unless enabled?
18
+ frames.last.each { |line| @io.puts(line) }
19
+ return
20
+ end
21
+
22
+ height = frames.first.length
23
+ frames.each_with_index do |lines, index|
24
+ lines.each { |line| @io.print("#{line}\e[K\n") }
25
+ @io.flush
26
+ sleep(REVEAL_FRAME_SECONDS)
27
+ @io.print("\e[#{height}A\r") unless index == frames.length - 1
28
+ end
29
+ end
30
+
31
+ def start_thinking
32
+ return unless enabled?
33
+
34
+ @stop = false
35
+ @thread = Thread.new do
36
+ tick = 0
37
+ until @stop
38
+ @io.print("\r #{Riffer::Rig::UI::Riffy.equalizer(@theme, tick)}\e[K")
39
+ @io.flush
40
+ sleep(SPINNER_FRAME_SECONDS)
41
+ tick += 1
42
+ end
43
+ end
44
+ end
45
+
46
+ def stop_thinking
47
+ return unless @thread
48
+
49
+ @stop = true
50
+ @thread.join
51
+ @thread = nil
52
+ @io.print("\r\e[K")
53
+ @io.flush
54
+ end
55
+
56
+ private
57
+
58
+ def enabled?
59
+ @theme.enabled && @io.respond_to?(:tty?) && @io.tty?
60
+ end
61
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Riffer::Rig::UI::Banner
4
+ extend self
5
+
6
+ WORDMARK = [
7
+ '██████╗ ██╗███████╗███████╗███████╗██████╗ ',
8
+ '██╔══██╗██║██╔════╝██╔════╝██╔════╝██╔══██╗',
9
+ '██████╔╝██║█████╗ █████╗ █████╗ ██████╔╝',
10
+ '██╔══██╗██║██╔══╝ ██╔══╝ ██╔══╝ ██╔══██╗',
11
+ '██║ ██║██║██║ ██║ ███████╗██║ ██║',
12
+ '╚═╝ ╚═╝╚═╝╚═╝ ╚═╝ ╚══════╝╚═╝ ╚═╝'
13
+ ].freeze
14
+
15
+ ROW_COLOURS = [
16
+ Riffer::Rig::UI::Palette::PINK,
17
+ Riffer::Rig::UI::Palette::MAGENTA,
18
+ Riffer::Rig::UI::Palette::PURPLE,
19
+ Riffer::Rig::UI::Palette::BLUE,
20
+ Riffer::Rig::UI::Palette::CYAN,
21
+ Riffer::Rig::UI::Palette::CYAN
22
+ ].freeze
23
+
24
+ INFO_LABEL_WIDTH = 8
25
+ INDENT = ' '
26
+ BEAR_GAP = ' '
27
+
28
+ def call(theme, model:, cwd:, context:, skills:, version:, glint_col: nil)
29
+ lines(theme, model: model, cwd: cwd, context: context, skills: skills, version: version, glint_col: glint_col).join("\n")
30
+ end
31
+
32
+ def lines(theme, model:, cwd:, context:, skills:, version:, glint_col: nil)
33
+ [''] + art(theme, glint_col: glint_col) + [''] +
34
+ info(theme, model: model, cwd: cwd, context: context, skills: skills, version: version) + ['']
35
+ end
36
+
37
+ def art(theme, glint_col: nil)
38
+ bear = Riffer::Rig::UI::Riffy.render(theme, glint_col: glint_col)
39
+ right = WORDMARK.each_index.map { |i| theme.paint(WORDMARK[i], ROW_COLOURS[i]) }
40
+ right += ['', "#{theme.grey('· code ·')} #{theme.cyan("♪ let's riff ♪")}"]
41
+
42
+ pad = (bear.length - right.length) / 2
43
+ bear.each_index.map do |i|
44
+ side = right[i - pad] if (pad...(pad + right.length)).cover?(i)
45
+ "#{INDENT}#{bear[i]}#{BEAR_GAP}#{side}".rstrip
46
+ end
47
+ end
48
+
49
+ def info(theme, model:, cwd:, context:, skills:, version:)
50
+ rows = { model: model, cwd: cwd, context: context, skills: skills, version: version }.map do |label, value|
51
+ " #{theme.magenta('▸')} #{theme.grey(label.to_s.ljust(INFO_LABEL_WIDTH))}#{value}"
52
+ end
53
+ rows + ['', " #{theme.dim('type /exit to quit')}"]
54
+ end
55
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Riffer::Rig::UI::Palette
4
+ PINK = [255, 106, 193].freeze
5
+ MAGENTA = [192, 76, 253].freeze
6
+ PURPLE = [122, 92, 255].freeze
7
+ BLUE = [77, 124, 255].freeze
8
+ CYAN = [45, 226, 230].freeze
9
+ GREY = [138, 139, 154].freeze
10
+ RED = [255, 92, 122].freeze
11
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Each character cell stacks two vertical pixels via half-block glyphs (▀ top,
4
+ # ▄ bottom): the foreground colours the top pixel and the background the bottom,
5
+ # doubling vertical resolution. A disabled theme falls back to plain block
6
+ # glyphs so piped/non-colour output stays escape-free.
7
+ module Riffer::Rig::UI::Pixels
8
+ extend self
9
+
10
+ RESET = "\e[0m"
11
+
12
+ def render(sprite, palette, theme)
13
+ sprite.each_slice(2).map do |top, bottom|
14
+ render_row(top, bottom || [], palette, theme)
15
+ end
16
+ end
17
+
18
+ private
19
+
20
+ def render_row(top, bottom, palette, theme)
21
+ width = [top.length, bottom.length].max
22
+ (0...width).map { |x| cell(palette[top[x]], palette[bottom[x]], theme) }.join
23
+ end
24
+
25
+ def cell(top_rgb, bottom_rgb, theme)
26
+ return ' ' if top_rgb.nil? && bottom_rgb.nil?
27
+
28
+ unless theme.enabled
29
+ return '█' if top_rgb && bottom_rgb
30
+ return '▀' if top_rgb
31
+
32
+ return '▄'
33
+ end
34
+
35
+ if top_rgb && bottom_rgb
36
+ "#{fg(top_rgb)}#{bg(bottom_rgb)}▀#{RESET}"
37
+ elsif top_rgb
38
+ "#{fg(top_rgb)}▀#{RESET}"
39
+ else
40
+ "#{fg(bottom_rgb)}▄#{RESET}"
41
+ end
42
+ end
43
+
44
+ def fg(rgb) = "\e[38;2;#{rgb[0]};#{rgb[1]};#{rgb[2]}m"
45
+
46
+ def bg(rgb) = "\e[48;2;#{rgb[0]};#{rgb[1]};#{rgb[2]}m"
47
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ class Riffer::Rig::UI::Renderer
6
+ RESULT_PREVIEW_LIMIT = 200
7
+
8
+ def initialize(io: $stdout, theme: Riffer::Rig::UI::Theme.for(io), tally: nil)
9
+ @io = io
10
+ @theme = theme
11
+ @tally = tally
12
+ end
13
+
14
+ def render(event)
15
+ case event
16
+ when Riffer::StreamEvents::TextDelta
17
+ @io.print(event.content)
18
+ when Riffer::StreamEvents::ToolCallDone
19
+ @io.puts("\n#{@theme.cyan("⚙ #{event.name}(#{format_arguments(event.arguments)})")}")
20
+ when Riffer::StreamEvents::SkillActivation
21
+ @io.puts("\n#{@theme.magenta("✦ skill: #{event.name}")}")
22
+ when Riffer::StreamEvents::Interrupt
23
+ @io.puts(@theme.dim("[interrupted: #{event.reason}]"))
24
+ when Riffer::StreamEvents::TokenUsageDone
25
+ render_token_usage(event.token_usage)
26
+ end
27
+ end
28
+
29
+ def render_tool_result(message)
30
+ return unless message.is_a?(Riffer::Messages::Tool)
31
+
32
+ line = "↳ #{preview(message.content)}"
33
+ @io.puts(message.error? ? @theme.red(line) : @theme.dim(line))
34
+ end
35
+
36
+ private
37
+
38
+ def render_token_usage(usage)
39
+ return unless @tally
40
+
41
+ @tally.add(usage)
42
+
43
+ parts = ["↑#{usage.input_tokens}", "↓#{usage.output_tokens}"]
44
+ parts << "cache_write:#{usage.cache_write_tokens}" if usage.cache_write_tokens&.positive?
45
+ parts << "cache_read:#{usage.cache_read_tokens}" if usage.cache_read_tokens&.positive?
46
+
47
+ session_parts = ["session #{@tally.total_tokens} tok"]
48
+ cost = @tally.estimated_cost
49
+ session_parts << format('~$%.4f', cost) if cost
50
+
51
+ @io.puts("\n#{@theme.dim("#{parts.join(' · ')} #{session_parts.join(' · ')}")}")
52
+ end
53
+
54
+ def format_arguments(arguments)
55
+ parsed = JSON.parse(arguments)
56
+ parsed.map { |key, value| "#{key}: #{value.inspect}" }.join(', ')
57
+ rescue JSON::ParserError
58
+ arguments
59
+ end
60
+
61
+ def preview(content)
62
+ first_line = content.to_s.lines.first.to_s.chomp
63
+ return first_line if first_line.length <= RESULT_PREVIEW_LIMIT
64
+
65
+ "#{first_line[0, RESULT_PREVIEW_LIMIT]}…"
66
+ end
67
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ # riffer-rig's mascot sprite, authored as a character grid (see LEGEND).
4
+ module Riffer::Rig::UI::Riffy
5
+ extend self
6
+
7
+ LEGEND = {
8
+ '.' => nil,
9
+ 'D' => :outline, # deep purple outline / shadow / nose
10
+ 'B' => :body, # purple fur
11
+ 'M' => :shade, # mid purple (inner ear, jaw shadow, leg lines)
12
+ 'L' => :light, # light lilac (snout, paw pads)
13
+ 'K' => :shades, # near-black sunglasses
14
+ 'W' => :glint # lens glint (intro only)
15
+ }.freeze
16
+
17
+ PALETTE = {
18
+ outline: [74, 46, 122],
19
+ body: [157, 92, 232],
20
+ shade: [120, 66, 190],
21
+ light: [212, 180, 250],
22
+ shades: [12, 10, 20],
23
+ glint: [240, 240, 255]
24
+ }.freeze
25
+
26
+ GRID = [
27
+ '...DDDD......DDDD...',
28
+ '..DBBBBD....DBBBBD..',
29
+ '..DBMMBD....DBMMBD..',
30
+ '..DBMMBD....DBMMBD..',
31
+ '..DBBDDDDDDDDDDBBD..',
32
+ '...DBBBBBBBBBBBBD...',
33
+ '...DKKKKKKKKKKKKD...',
34
+ '...DKKKKKBBKKKKKD...',
35
+ '...DBKKKBBBBKKKBD...',
36
+ '...DBBBBBDDBBBBBD...',
37
+ '...DBBBBLLLLBBBBD...',
38
+ '...DBBBMLLLLMBBBD...',
39
+ '....DBBBBBBBBBBD....',
40
+ '....DDMMMMMMMMDD....',
41
+ '.....DBBBBBBBBD.....',
42
+ '....DBBBBBBBBBBD....',
43
+ '...DBBBBBBBBBBBBD...',
44
+ '..DBBBBBBBBBBBBBBD..',
45
+ '.DBBBBBBBBBBBBBBBBD.',
46
+ '.DBBBBDBBDDBBDBBBBD.',
47
+ '.DLLLDDLLDDLLDDLLLD.'
48
+ ].freeze
49
+
50
+ WIDTH = GRID.first.length
51
+ HEIGHT = (GRID.length + 1) / 2
52
+
53
+ LENS_ROWS = [6, 7, 8].freeze
54
+ GLINT_COLS = [4, 5, 6, 7, 8, 11, 12, 13, 14, 15].freeze
55
+
56
+ EQ_LEVELS = '▁▂▃▄▅▆▇█'.chars.freeze
57
+ EQ_BARS = 7
58
+
59
+ def render(theme, glint_col: nil)
60
+ sprite = GRID.map { |row| row.chars.map { |char| LEGEND.fetch(char) } }
61
+ apply_glint(sprite, glint_col) if glint_col
62
+ Riffer::Rig::UI::Pixels.render(sprite, PALETTE, theme)
63
+ end
64
+
65
+ def equalizer(theme, tick)
66
+ bars = Array.new(EQ_BARS) do |i|
67
+ height = (Math.sin((tick + i) * 0.6).abs * (EQ_LEVELS.length - 1)).round
68
+ bar = EQ_LEVELS[height]
69
+ i.even? ? theme.cyan(bar) : theme.magenta(bar)
70
+ end
71
+ "#{bars.join} #{theme.grey('riffing…')}"
72
+ end
73
+
74
+ private
75
+
76
+ def apply_glint(sprite, column)
77
+ LENS_ROWS.each do |row|
78
+ sprite[row][column] = :glint if sprite[row][column] == :shades
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ # +enabled+ gates every escape so the same code path emits plain, escape-free
4
+ # text when piped or tested.
5
+ Riffer::Rig::UI::Theme = Data.define(:enabled)
6
+
7
+ # Behaviour lives in a reopened class (rather than the `Data.define` block) so
8
+ # the type checker can analyse the methods.
9
+ class Riffer::Rig::UI::Theme
10
+ def self.for(io)
11
+ new(enabled: io.respond_to?(:tty?) && io.tty? && !ENV.key?('NO_COLOR'))
12
+ end
13
+
14
+ def paint(text, rgb)
15
+ return text.to_s unless enabled
16
+
17
+ "\e[38;2;#{rgb[0]};#{rgb[1]};#{rgb[2]}m#{text}\e[0m"
18
+ end
19
+
20
+ def pink(text) = paint(text, Riffer::Rig::UI::Palette::PINK)
21
+
22
+ def magenta(text) = paint(text, Riffer::Rig::UI::Palette::MAGENTA)
23
+
24
+ def purple(text) = paint(text, Riffer::Rig::UI::Palette::PURPLE)
25
+
26
+ def blue(text) = paint(text, Riffer::Rig::UI::Palette::BLUE)
27
+
28
+ def cyan(text) = paint(text, Riffer::Rig::UI::Palette::CYAN)
29
+
30
+ def grey(text) = paint(text, Riffer::Rig::UI::Palette::GREY)
31
+
32
+ def red(text) = paint(text, Riffer::Rig::UI::Palette::RED)
33
+
34
+ def dim(text)
35
+ return text.to_s unless enabled
36
+
37
+ "\e[2m#{text}\e[0m"
38
+ end
39
+
40
+ def bold(text)
41
+ return text.to_s unless enabled
42
+
43
+ "\e[1m#{text}\e[0m"
44
+ end
45
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Riffer
4
+ module Rig
5
+ VERSION = '0.1.0'
6
+ end
7
+ end
data/lib/riffer/rig.rb ADDED
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'zeitwerk'
4
+ require 'riffer'
5
+ require_relative 'rig/version'
6
+
7
+ loader = Zeitwerk::Loader.for_gem_extension(Riffer)
8
+ loader.ignore("#{__dir__}/rig/version.rb")
9
+ loader.inflector.inflect('cli' => 'CLI', 'repl' => 'REPL', 'ui' => 'UI')
10
+ loader.setup
metadata ADDED
@@ -0,0 +1,123 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: riffer-rig
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Jake Bottrall
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: anthropic
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '1.69'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '1.69'
26
+ - !ruby/object:Gem::Dependency
27
+ name: base64
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '0.2'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '0.2'
40
+ - !ruby/object:Gem::Dependency
41
+ name: riffer
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: 0.45.0
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: 0.45.0
54
+ - !ruby/object:Gem::Dependency
55
+ name: zeitwerk
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: '2.8'
61
+ type: :runtime
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: '2.8'
68
+ description: An interactive terminal coding agent (read/write/edit/bash) built on
69
+ the riffer agentic framework.
70
+ email:
71
+ - jakebottrall@gmail.com
72
+ executables:
73
+ - riffer
74
+ extensions: []
75
+ extra_rdoc_files: []
76
+ files:
77
+ - CHANGELOG.md
78
+ - README.md
79
+ - exe/riffer
80
+ - lib/riffer/rig.rb
81
+ - lib/riffer/rig/cli.rb
82
+ - lib/riffer/rig/coding_agent.rb
83
+ - lib/riffer/rig/credentials.rb
84
+ - lib/riffer/rig/repl.rb
85
+ - lib/riffer/rig/settings.rb
86
+ - lib/riffer/rig/token_tally.rb
87
+ - lib/riffer/rig/tools/bash.rb
88
+ - lib/riffer/rig/tools/edit.rb
89
+ - lib/riffer/rig/tools/read.rb
90
+ - lib/riffer/rig/tools/write.rb
91
+ - lib/riffer/rig/ui/animator.rb
92
+ - lib/riffer/rig/ui/banner.rb
93
+ - lib/riffer/rig/ui/palette.rb
94
+ - lib/riffer/rig/ui/pixels.rb
95
+ - lib/riffer/rig/ui/renderer.rb
96
+ - lib/riffer/rig/ui/riffy.rb
97
+ - lib/riffer/rig/ui/theme.rb
98
+ - lib/riffer/rig/version.rb
99
+ homepage: https://github.com/bottrall/riffer-rig
100
+ licenses:
101
+ - MIT
102
+ metadata:
103
+ rubygems_mfa_required: 'true'
104
+ changelog_uri: https://github.com/bottrall/riffer-rig/blob/main/CHANGELOG.md
105
+ source_code_uri: https://github.com/bottrall/riffer-rig
106
+ rdoc_options: []
107
+ require_paths:
108
+ - lib
109
+ required_ruby_version: !ruby/object:Gem::Requirement
110
+ requirements:
111
+ - - ">="
112
+ - !ruby/object:Gem::Version
113
+ version: '4.0'
114
+ required_rubygems_version: !ruby/object:Gem::Requirement
115
+ requirements:
116
+ - - ">="
117
+ - !ruby/object:Gem::Version
118
+ version: '0'
119
+ requirements: []
120
+ rubygems_version: 4.0.10
121
+ specification_version: 4
122
+ summary: A dead-simple terminal coding agent built on riffer.
123
+ test_files: []