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 +7 -0
- data/CHANGELOG.md +23 -0
- data/README.md +79 -0
- data/exe/riffer +8 -0
- data/lib/riffer/rig/cli.rb +99 -0
- data/lib/riffer/rig/coding_agent.rb +54 -0
- data/lib/riffer/rig/credentials.rb +69 -0
- data/lib/riffer/rig/repl.rb +86 -0
- data/lib/riffer/rig/settings.rb +119 -0
- data/lib/riffer/rig/token_tally.rb +58 -0
- data/lib/riffer/rig/tools/bash.rb +58 -0
- data/lib/riffer/rig/tools/edit.rb +32 -0
- data/lib/riffer/rig/tools/read.rb +31 -0
- data/lib/riffer/rig/tools/write.rb +21 -0
- data/lib/riffer/rig/ui/animator.rb +61 -0
- data/lib/riffer/rig/ui/banner.rb +55 -0
- data/lib/riffer/rig/ui/palette.rb +11 -0
- data/lib/riffer/rig/ui/pixels.rb +47 -0
- data/lib/riffer/rig/ui/renderer.rb +67 -0
- data/lib/riffer/rig/ui/riffy.rb +81 -0
- data/lib/riffer/rig/ui/theme.rb +45 -0
- data/lib/riffer/rig/version.rb +7 -0
- data/lib/riffer/rig.rb +10 -0
- metadata +123 -0
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,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
|
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: []
|