terret-fortune
A worked example of the Terret extension story: a
third-party gem that ships one tool and joins an agent's boot by shipping
normally. Install it, name it in a profile, permit the tool, and a Terret agent
can call fortune — one short, pithy line from a vendored list.
This gem is the running example of Terret's docs/cookbook/adding-a-tool.md and
docs/cookbook/adding-a-bundle.md, kept in lockstep with those pages: what they
describe generically, this repository is concretely.
What it is
fortune is the smallest honest tool there is: a pure read with no side
effect. Its metadata says so — mutating: false, approval: :never,
concurrency: :parallel — and those three fields are what the loop's tool
barrier and the approvals gate act on, so they are the truth about what calling
it does, not a convenience.
terret-fortune/
├── terret-fortune.gemspec # declares the gem a bundle (metadata["terret"])
├── config/
│ └── bundle.yml # one row mounting Terret::Fortune::Tool
├── lib/
│ └── terret/
│ ├── fortune.rb # entry: requires terret-core, then the service
│ └── fortune/
│ ├── tool.rb # the Hames service that registers `fortune`
│ └── fortunes.txt # the vendored list
└── test/
└── fortune_test.rb # boots the service, drives the real tool pipeline
The extension story, end to end
1. Write the tool
A tool provider is an ordinary Hames service: it injects ctx[:tools] and
registers a Definition in start. lib/terret/fortune/tool.rb is the whole
of it — the same shape gems/terret-tools-std uses for the standard roster.
2. Declare the gem a bundle
One line of gemspec metadata makes the gem discoverable. The value is the string path to the bundle file — RubyGems validates every metadata value as a String, so a nested hash does not build at all:
# terret-fortune.gemspec
s. = { "terret" => "config/bundle.yml" }
s.add_dependency "terret-core", "~> 0.1" # the code the row mounts
The bundle file is an ordered list of rows plus the requires its constants need:
# config/bundle.yml
name: terret-fortune
requires:
- terret/fortune
rows:
- id: fortune
plugin: Terret::Fortune::Tool
3. Install it
gem install terret-fortune
# or, in a Gemfile:
# gem "terret-fortune"
gem install is the whole registration mechanism. Terret's discovery walks
every gemspec it can see, reads the terret metadata key, and parses the file
it points at — so a third-party gem becomes discoverable by shipping normally.
Nothing to register, no directory to drop a file into.
4. Stack it in a profile
A profile names bundles by gem name, in stack order, terret (terret-base)
always layer one:
# ~/.terret/profiles/headless/profile.yml
bundles:
- terret # terret-base, always first
- terret-fortune # this gem
5. Permit the tool
This is the step people forget: mounting the tool does not make it
callable. Terret's allow list is deny-by-default, and the base floor names
exactly the standard roster and nothing else. fortune is denied until a
profile permits it — and because a patch replaces a row's config wholesale,
you restate the whole list, not just the new name:
# ~/.terret/profiles/headless/patch.yml
rows:
- id: allow_list
config:
patterns:
- Read
- Write
- Edit
- Glob
- Grep
- Bash
- fortune # the new tool, now permitted
# ... restate the rest of the roster you still want
6. Boot
ctx = Terret.boot(profile: "headless")
That resolves the layers, discovers terret-fortune, requires its code, mounts
its row, and registers the tool. From here the agent can call fortune.
Running the tests
# Against a published terret-core:
bundle install
bundle exec rake test
# Against a local Terret monorepo checkout (no publish needed):
TERRET_CHECKOUT=/path/to/terret bundle exec rake test
License
MIT. See LICENSE.