Module: Nexo::Skills

Defined in:
lib/nexo/skills.rb

Overview

Composes the ruby_llm-skills gem so a developer can drop a SKILL.md package into app/skills/<name>/ and attach it to an agent with a single skills class macro — no loader wiring.

ruby_llm-skills is a SOFT (optional) runtime dependency: it is required lazily by load! the first time a skill is used. With the gem absent, require "nexo" still loads cleanly; only touching a skill raises MissingDependencyError with install guidance.

A loaded skill contributes its instructions (the SKILL.md body) to a chat. It ships no independent tools, so attaching a skill never widens the agent's effective capabilities.

Its scripts/, assets/ and references/ files are NOT reachable on their own: a skill lives under skills_path, every sandbox confines file access to its own working directory, and nothing bridges the two. To let an agent read or run a skill's bundled files, copy them into the sandbox first — through the sandbox's own #write, so it works on every tier. They are then reached through Nexo's permission-gated tools like any other workspace file. See the spec's "Verified APIs" / safety resolution for why the gem's progressive disclosure SkillTool (which does ungated File.read) is deliberately not attached.

Class Method Summary collapse

Class Method Details

.find(name) ⇒ RubyLLM::Skills::Skill

Resolves a skill name (symbol or string) to a loaded skill object read from the filesystem under Nexo.config.skills_path. The filesystem is the only skill source in v1 — zip/DB/remote loading is deferred entirely to ruby_llm-skills.

Calls load! first (so an absent gem surfaces MissingDependencyError), then resolves <skills_path>/<name>/SKILL.md. A missing file raises Error whose message names the resolved path.

Returns:

  • (RubyLLM::Skills::Skill)

    the loaded skill (exposes #content, #name, #description).

Raises:



49
50
51
52
53
54
55
56
57
# File 'lib/nexo/skills.rb', line 49

def find(name)
  load!

  dir = File.join(Nexo.config.skills_path.to_s, name.to_s)
  skill_md = File.join(dir, "SKILL.md")
  raise Error, "skill not found: #{skill_md}" unless File.exist?(skill_md)

  RubyLLM::Skills.load(dir)
end

.load!Object

Lazily loads the ruby_llm-skills gem. Idempotent (a second call is a cheap no-op once the gem is loaded). Raises MissingDependencyError — naming the gem and the exact remedy — when the gem is not installed.



31
32
33
34
35
36
# File 'lib/nexo/skills.rb', line 31

def load!
  require "ruby_llm/skills"
rescue LoadError
  raise MissingDependencyError,
    'Skills require the `ruby_llm-skills` gem. Add `gem "ruby_llm-skills"` to your Gemfile.'
end

.materialize(name, into:, kinds: %i[scripts assets references],, overwrite: true) ⇒ Object

Copies a skill's bundled files INTO a sandbox, so an agent can read or run them through its permission-gated tools.

A skill lives under skills_path, outside every sandbox, and each sandbox confines file access to its own working directory — so scripts/, assets/ and references/ are unreachable until they are staged. This is that step.

It goes through the sandbox's own #write, which is the ONLY route that works on every tier: Local writes to the filesystem, Container streams over docker exec / container exec, and Remote hands off to the injected client. Nothing here knows or cares which one it is.

Nexo::Skills.materialize(:dashboard_designer, into: agent.sandbox)
# => { scripts: ["scripts/render_dashboard.rb"],
#      assets:  ["assets/dashboard-template.html"] }

Returns the SANDBOX-RELATIVE path of every file written, grouped by kind, so a caller can build a command without knowing where the sandbox lives. Build commands from these relative paths — a host-absolute path is meaningless inside a container and on another machine.

Kinds a skill ships nothing for are omitted. kinds: narrows the copy; overwrite: false skips files already present, which is what you want against an image that already bakes the skill in.

SECURITY: staged files land in WRITABLE space. An agent holding :write and :shell can rewrite a script before executing it — where the same file, left outside the sandbox, could not be touched at all. Re-materializing on every run bounds tampering to a single turn; if that is not enough, keep the resource out of the sandbox and feed the agent its contents another way.



89
90
91
92
93
94
95
96
97
98
# File 'lib/nexo/skills.rb', line 89

def materialize(name, into:, kinds: %i[scripts assets references], overwrite: true)
  skill = name.respond_to?(:scripts) ? name : find(name)

  Array(kinds).each_with_object({}) do |kind, staged|
    files = resources(skill, kind)
    next if files.empty?

    staged[kind] = files.map { |host_path| copy_in(host_path, kind, into, overwrite) }
  end
end