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.