rubino-agent 0.3.0 → 0.4.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: db2afff4be97c340cbe86b9fb1b7509f1457abdfe812ac8c376e03421229ada1
4
- data.tar.gz: efc19c01b592b21c70304f82fe9dbc4b76fea60b9dbdd63ed15fe5b5d0548f57
3
+ metadata.gz: a20b85c30f537ff5bdaf5abec6300eedfcda791d76b899ac3fcd972b71854ac1
4
+ data.tar.gz: d5692a31f13e338690a3be90bc4d96d27251ddad165b706afee7e0fcdee3fea7
5
5
  SHA512:
6
- metadata.gz: fb700fb3a191d046737ad40b53d69c66a1e2c0cba573e04c6d3cfa803fa94cc2f1c74c09390ebda03424e6b30d19b9d4a948ee1ecfaba44926576b31c92d089a
7
- data.tar.gz: 2eef17e973b05eb0eb5e6c0f7ee79ac8c8c440ea32f2c6ef589c06512319d0b03fac46879a480ee92923bf18167ede7fbd9ae14178ede83e32571058791890f6
6
+ metadata.gz: 865a203c91311f039d90a98b4e88ff8f792d0049e7370f10c847e2aeb77458f2f39d80485f133a79d8c2e8d9c1eeb5dec369f53320d210ff79928965a8c0a3f2
7
+ data.tar.gz: a9435d6746bc743e65db73574e13d4cd65445bf97ec5c9e2387737c176e7ab8726991062cc852a6c295910e3876d8b4a1d8c96420f5a8dd6a83dabf68a280546
data/CHANGELOG.md CHANGED
@@ -2,6 +2,41 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.4.0] - 2026-06-13
6
+
7
+ ### Added — skills from git (#4)
8
+
9
+ - **`rubino skills install <owner/repo | git-URL>`** — install skills from any
10
+ git repo shipping the `<name>/SKILL.md` layout (`--skill NAME` / `--all` /
11
+ `--list`; `--documents` is shorthand for the four `anthropics/skills`
12
+ document skills). Provenance lands in `~/.rubino/skills/.sources.json`, so
13
+ **`rubino skills update`** re-fetches from the recorded source (up-to-date vs
14
+ updated by commit) and **`rubino skills remove NAME`** only deletes what this
15
+ mechanism installed. `rubino skills list` gains a Source column.
16
+ - The skill registry now also discovers the agent-neutral `.agents/skills/`
17
+ and `~/.agents/skills/` dirs (the `npx skills` / Gemini CLI convention) —
18
+ additive, lowest precedence, trust-gated like `.rubino/skills`.
19
+
20
+ ### Added
21
+
22
+ - **`/agents <id>` watch — live tool-output tail (#5).** The drill-in watch grows an `output:` block showing the tail of the running subagent's current tool output, clearing when the tool finishes.
23
+ - **`soffice` and `qpdf` in the `[Environment]` probe (#4/#6)** so the agent honestly reports whether LibreOffice/qpdf are available for the document skills.
24
+
25
+ ### Fixed
26
+
27
+ - **`read_attachment` extension-spoof gate now covers document MIMEs (#239).** A text file named `report.docx` reads inline as text instead of bouncing off the document converter; a real `.docx` (ZIP magic) still classifies as a document.
28
+ - **No more CLI crash under a C/POSIX locale (#250).** Skill and context files are read as UTF-8 rather than the ambient (US-ASCII) encoding, so `rubino skills list` and prompt assembly no longer raise `invalid byte sequence` on minimal Linux/Docker images.
29
+ - **Installer no longer always exits 1 on a fresh Linux box (#240).** Fixed an unbound `rv_bin` under `set -u` and an invalid `gem environment gembindir` call; `curl … | bash` now installs cleanly and is idempotent.
30
+
31
+ ### Internal
32
+
33
+ - **Test stability (#236):** PTY capture specs read to the child's EOF instead of treating a 0.5s quiet window as end-of-output, removing a rare 2-failure flake under concurrent suite load.
34
+ - **Approval-handoff guard (#10):** the #80 unit guard now genuinely fails on a full revert; the PTY handoff spec is relabeled as a happy-path check.
35
+
36
+ ## [0.3.0] - 2026-06-06
37
+
38
+ Major capability release: the core conversation loop was ported 1:1 from the reference implementation (formalized LLM boundary, retry/backoff/fallback, degenerate-response recovery), background subagents became the default delegation path, the memory subsystem grew a pluggable backend contract with a tiny-Zep SQLite backend that is now the default, CLI gained image/file input and a scroll-native redesign, and a reference-aligned approval model (hardline floor, dangerous-pattern deny, prefix-derived rules) landed. Consolidated from `feature/subagent-view` (#48) plus #49-#58.
39
+
5
40
  ### Added — CLI redesign & in-chat surfaces
6
41
 
7
42
  A scroll-native `rubino chat` refresh plus several new slash commands and input affordances. All are documented under [docs/commands.md](docs/commands.md) and [docs/configuration.md](docs/configuration.md).
@@ -107,10 +142,6 @@ The GitHub repository is `github.com/Jhonnyr97/rubino-agent`. Publishing the ren
107
142
 
108
143
  - `docs/api/v1.md` aligned to the real API surface (#165, #166, #167): SSE catalogue documents the non-streaming contract (no `message.delta`/`reasoning.delta`), the approval decision enum lists all seven accepted values with semantics, and `GET /v1/sessions`, `/v1/memory*`, `/v1/tasks*` are documented. A doc-drift spec locks the documented route list to the registered routes.
109
144
 
110
- ## [0.3.0] - 2026-06-06
111
-
112
- Major capability release: the core conversation loop was ported 1:1 from the reference implementation (formalized LLM boundary, retry/backoff/fallback, degenerate-response recovery), background subagents became the default delegation path, the memory subsystem grew a pluggable backend contract with a tiny-Zep SQLite backend that is now the default, CLI gained image/file input and a scroll-native redesign, and a reference-aligned approval model (hardline floor, dangerous-pattern deny, prefix-derived rules) landed. Consolidated from `feature/subagent-view` (#48) plus #49-#58.
113
-
114
145
  ### Breaking / upgrade notes
115
146
 
116
147
  - **Default memory backend is now SQLite (tiny-Zep).** `memory.backend` now defaults to `"sqlite"` (previously `"default"`). The new backend reads/writes the `:memory_facts` table; the old `"default"` backend used the `:memories` table. On upgrade, users who were on the previous `"default"` backend and do **not** pin `memory.backend: "default"` in their config will stop reading their prior memory store — the new backend looks only at `:memory_facts`. Your old data in `:memories` is **not deleted**, just no longer read. **No automatic backfill is shipped.** To keep old recall, pin `memory.backend: "default"` in config. (Acceptable for alpha; documented here.)
data/docs/commands.md CHANGED
@@ -13,7 +13,7 @@ Two surfaces: **CLI subcommands** (run from your shell) and **slash commands** (
13
13
  | `rubino memory SUBCOMMAND` | Manage persistent memories (`list` / `show` / `delete` / `backend`) |
14
14
  | `rubino sessions SUBCOMMAND` | Manage chat sessions |
15
15
  | `rubino jobs SUBCOMMAND` | Manage background jobs |
16
- | `rubino skills SUBCOMMAND` | Manage skills (`list` / `show` / `enable` / `disable`) |
16
+ | `rubino skills SUBCOMMAND` | Manage skills (`list` / `show` / `enable` / `disable` / `install` / `update` / `remove`) |
17
17
  | `rubino tools` | List available tools and their enabled/disabled state |
18
18
  | `rubino server` | Start the JSON API + SSE server |
19
19
  | `rubino tls-cert` | Print the agent's self-signed TLS certificate PEM (generating it if absent) |
@@ -129,10 +129,15 @@ rubino jobs list
129
129
  rubino jobs process # run pending jobs now (manual mode)
130
130
  rubino jobs worker # start a background worker
131
131
 
132
- rubino skills list # list skills with enabled/disabled markers
132
+ rubino skills list # list skills with enabled/disabled markers + provenance
133
133
  rubino skills show NAME # print a skill's SKILL.md body (review before enabling)
134
134
  rubino skills enable NAME # put a skill back in the index (every session)
135
135
  rubino skills disable NAME # drop a skill from the index (every session)
136
+
137
+ rubino skills install owner/repo --skill NAME # install skills from a git repo (#4)
138
+ rubino skills install --documents # anthropics/skills: pdf docx pptx xlsx
139
+ rubino skills update [NAME ...] # re-fetch from the recorded sources
140
+ rubino skills remove NAME # delete an installed skill + provenance
136
141
  ```
137
142
 
138
143
  `config get`/`config show` mask secret-named keys (`api_key`, tokens, …) on display — the file keeps the real value. See [memory.md](memory.md) for the memory backends, [jobs.md](jobs.md) for the queue/cron system and [skills.md](skills.md) for the skill model.
data/docs/skills.md CHANGED
@@ -31,6 +31,12 @@ skills:
31
31
  Override the search paths via the `skills.paths` config key. On a name collision
32
32
  the **directory** layout wins over the flat-file layout (it is the richer unit).
33
33
 
34
+ The registry additionally scans the **agent-neutral** skill dirs — project
35
+ `.agents/skills/` and `~/.agents/skills/` (the emerging cross-agent convention
36
+ used by `npx skills`, Gemini CLI, goose) — at the lowest precedence: a
37
+ rubino-path skill of the same name wins, and nothing changes when those dirs
38
+ are absent. The project-local one is trust-gated exactly like `.rubino/skills`.
39
+
34
40
  ### Two layouts
35
41
 
36
42
  | Layout | Path | Skill name | Bundled files |
@@ -59,6 +65,31 @@ skills:
59
65
  include_builtin: false # default true
60
66
  ```
61
67
 
68
+ ### Installing skills from git (`rubino skills install`)
69
+
70
+ Any git repo shipping the `<name>/SKILL.md` layout is a skill source — there is
71
+ no marketplace and nothing else is vendored in the gem. Skills are
72
+ shallow-cloned and copied into `~/.rubino/skills`, where the registry discovers
73
+ them like any hand-written skill:
74
+
75
+ ```bash
76
+ rubino skills install anthropics/skills --list # see what a source ships
77
+ rubino skills install anthropics/skills --skill pdf # pick by name (repeatable)
78
+ rubino skills install owner/repo --all # take everything
79
+ rubino skills install https://gitlab.com/o/r.git # any git URL works
80
+ rubino skills install --documents # anthropics/skills: pdf docx pptx xlsx
81
+ rubino skills update # re-fetch installed skills
82
+ rubino skills remove NAME # delete dir + provenance
83
+ ```
84
+
85
+ With no `--skill`/`--all` and multiple skills in the source, the CLI prints the
86
+ catalogue and asks you to pick (off a TTY it just prints the hint). Provenance
87
+ is recorded per installed skill in `~/.rubino/skills/.sources.json`
88
+ (`name → {source, path, commit}`): `rubino skills list` shows it in the Source
89
+ column, and `update` re-fetches from the recorded source, reporting
90
+ *up to date* vs *updated* by comparing commits. `remove` only deletes skills
91
+ this mechanism installed — hand-written skills are never touched.
92
+
62
93
  ### Authoring a `SKILL.md`
63
94
 
64
95
  A `SKILL.md` is YAML frontmatter followed by the instruction body:
data/install.sh CHANGED
@@ -144,7 +144,7 @@ setup_ruby_rv() {
144
144
  return 1
145
145
  }
146
146
 
147
- local rv_bin
147
+ # NOTE: rv_bin is intentionally NOT local: rubyx() reads it after we return.
148
148
  if rv_bin="$(locate_rv)"; then
149
149
  ok "rv already installed: ${rv_bin}"
150
150
  else
@@ -198,10 +198,11 @@ case "$METHOD" in
198
198
  *) die "internal: unknown method '${METHOD}'." ;;
199
199
  esac
200
200
 
201
- # Where gem-installed executables land for the chosen Ruby. `gem environment
202
- # gembindir` is correct for both rv and Homebrew; fall back to the ruby bin dir.
203
- GEM_BIN_DIR="$(rubyx gem environment gembindir 2>/dev/null | tail -n1)" || GEM_BIN_DIR=""
204
- [ -n "${GEM_BIN_DIR:-}" ] && [ -d "$GEM_BIN_DIR" ] || GEM_BIN_DIR="$RUBY_BIN_DIR"
201
+ # Where gem-installed executables land for the chosen Ruby. Ask RubyGems
202
+ # directly (Gem.bindir); fall back to the ruby bin dir. The dir may not exist
203
+ # yet on a fresh machine — `gem install` creates it so don't require it.
204
+ GEM_BIN_DIR="$(rubyx ruby -e 'print Gem.bindir' 2>/dev/null)" || GEM_BIN_DIR=""
205
+ [ -n "${GEM_BIN_DIR:-}" ] || GEM_BIN_DIR="$RUBY_BIN_DIR"
205
206
 
206
207
  # --- install the rubino gem -------------------------------------------------
207
208
 
@@ -35,19 +35,35 @@ module Rubino
35
35
  ].freeze
36
36
  IMAGE_EXTS = %w[.png .jpg .jpeg .gif .webp .bmp .tiff .tif].freeze
37
37
 
38
- # Leading magic bytes per recognised image MIME (WebP is special-cased:
39
- # RIFF container + WEBP tag). Marcel lets the file NAME break the tie
40
- # when the content sniff only yields a generic type (text/plain,
41
- # octet-stream), so a text file renamed fake.png came back image/png and
42
- # was shipped to the provider (#158). An image verdict must therefore be
43
- # backed by the actual signature.
44
- IMAGE_SIGNATURES = {
38
+ # Leading magic bytes per recognised image/document MIME (WebP is
39
+ # special-cased: RIFF container + WEBP tag). Marcel lets the file NAME
40
+ # break the tie when the content sniff only yields a generic type
41
+ # (text/plain, octet-stream), so a text file renamed fake.png came back
42
+ # image/png and was shipped to the provider (#158) and a text file
43
+ # renamed report.docx came back as :document and got a shell-hint
44
+ # instead of reading inline (#239). An image or document verdict must
45
+ # therefore be backed by the actual signature.
46
+ OLE2 = "\xD0\xCF\x11\xE0\xA1\xB1\x1A\xE1".b
47
+ SIGNATURES = {
45
48
  "image/png" => ["\x89PNG\r\n\x1a\n".b],
46
49
  "image/jpeg" => ["\xFF\xD8\xFF".b],
47
50
  "image/gif" => ["GIF87a".b, "GIF89a".b],
48
51
  "image/bmp" => ["BM".b],
49
52
  "image/x-ms-bmp" => ["BM".b],
50
- "image/tiff" => ["II*\x00".b, "MM\x00*".b]
53
+ "image/tiff" => ["II*\x00".b, "MM\x00*".b],
54
+ "application/pdf" => ["%PDF".b],
55
+ # OOXML and ODF are ZIP containers.
56
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document" => ["PK".b],
57
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" => ["PK".b],
58
+ "application/vnd.openxmlformats-officedocument.presentationml.presentation" => ["PK".b],
59
+ "application/vnd.oasis.opendocument.text" => ["PK".b],
60
+ "application/vnd.oasis.opendocument.spreadsheet" => ["PK".b],
61
+ # Legacy Office is an OLE2 compound file.
62
+ "application/msword" => [OLE2],
63
+ "application/vnd.ms-excel" => [OLE2],
64
+ "application/vnd.ms-powerpoint" => [OLE2],
65
+ "application/rtf" => ["{\\rtf".b],
66
+ "text/rtf" => ["{\\rtf".b]
51
67
  }.freeze
52
68
 
53
69
  module_function
@@ -97,12 +113,14 @@ module Rubino
97
113
  basename = File.basename(real)
98
114
  mime = Marcel::MimeType.for(Pathname(real), name: basename).to_s
99
115
 
100
- # Extension-spoof gate (#158): an image verdict that the magic bytes
101
- # don't back up came from the extension, not the content. Re-resolve
102
- # from content alone (no name:); when that is generic too, the text/
103
- # binary sniff names the honest type — so fake.png full of text is
104
- # rejected at the staging gate as text/plain, before any network call.
105
- if IMAGE_MIMES.include?(mime) && !image_signature?(real, mime)
116
+ # Extension-spoof gate (#158, #239): an image or document verdict that
117
+ # the magic bytes don't back up came from the extension, not the
118
+ # content. Re-resolve from content alone (no name:); when that is
119
+ # generic too, the text/binary sniff names the honest type — so
120
+ # fake.png full of text is rejected at the staging gate as text/plain
121
+ # before any network call, and report.docx full of text reads inline
122
+ # as text instead of bouncing off the document converter.
123
+ if (IMAGE_MIMES.include?(mime) || DOCUMENT_MIMES.include?(mime)) && !signature?(real, mime)
106
124
  mime = Marcel::MimeType.for(Pathname(real)).to_s
107
125
  if mime.empty? || mime == "application/octet-stream"
108
126
  return base_helper.send(:binary?, real) ? [:binary, "application/octet-stream"] : [:text, "text/plain"]
@@ -136,12 +154,12 @@ module Rubino
136
154
  end
137
155
 
138
156
  # True when the file's leading bytes carry the signature +mime+ claims.
139
- # Unknown image MIMEs fail closed (no signature -> not verified).
140
- def image_signature?(real, mime)
157
+ # MIMEs without a known signature fail closed (not verified).
158
+ def signature?(real, mime)
141
159
  head = File.binread(real, 16).to_s.b
142
160
  return head.start_with?("RIFF") && head[8, 4] == "WEBP" if mime == "image/webp"
143
161
 
144
- Array(IMAGE_SIGNATURES[mime]).any? { |sig| head.start_with?(sig) }
162
+ Array(SIGNATURES[mime]).any? { |sig| head.start_with?(sig) }
145
163
  end
146
164
 
147
165
  # JSON/XML/YAML/JS and friends arrive as application/* but are text.
@@ -183,7 +183,7 @@ module Rubino
183
183
  desc "jobs SUBCOMMAND", "Manage background jobs"
184
184
  subcommand "jobs", JobsCommand
185
185
 
186
- desc "skills SUBCOMMAND", "Manage skills (list, show, enable, disable)"
186
+ desc "skills SUBCOMMAND", "Manage skills (list, enable, install, update)"
187
187
  subcommand "skills", SkillsCommand
188
188
 
189
189
  desc "tools", "List available tools"
@@ -10,10 +10,18 @@ module Rubino
10
10
  # run the SAME registry-validated StateRepository write the HTTP API
11
11
  # toggle and the in-chat `/skills enable|disable` use (Skills::Toggle) —
12
12
  # no new logic, just the missing terminal surface.
13
+ #
14
+ # `install`/`update`/`remove` (#4) manage skills fetched from git repos
15
+ # (Skills::Installer): any repo shipping the registry's `<name>/SKILL.md`
16
+ # layout is a source — no marketplace, nothing vendored in the gem.
13
17
  class SkillsCommand < Thor
14
18
  # Clean `tree`/help label instead of the underscored class-name default (F12).
15
19
  namespace "rubino skills"
16
20
 
21
+ # The `--documents` shorthand (#4): Anthropic's four document skills.
22
+ DOCUMENTS_SOURCE = "anthropics/skills"
23
+ DOCUMENT_SKILLS = %w[pdf docx pptx xlsx].freeze
24
+
17
25
  def self.exit_on_failure?
18
26
  true
19
27
  end
@@ -29,10 +37,12 @@ module Rubino
29
37
  return
30
38
  end
31
39
 
40
+ sources = Skills::Installer.new.sources
32
41
  rows = skills.map do |skill|
33
- [skill.name, skill_status(skill.name, registry), skill.description.to_s]
42
+ [skill.name, skill_status(skill.name, registry), provenance(skill.name, sources),
43
+ skill.description.to_s]
34
44
  end
35
- Rubino.ui.table(headers: %w[Name Status Description], rows: rows)
45
+ Rubino.ui.table(headers: %w[Name Status Source Description], rows: rows)
36
46
  end
37
47
 
38
48
  desc "show NAME", "Print a skill's SKILL.md body (review it before enabling)"
@@ -56,6 +66,70 @@ module Rubino
56
66
  toggle(name, enabled: false)
57
67
  end
58
68
 
69
+ desc "install [SOURCE]", "Install skills from a git repo (owner/repo shorthand or git URL)"
70
+ option :skill, type: :string, repeatable: true,
71
+ desc: "Skill name to install from the source (repeatable)"
72
+ option :all, type: :boolean, desc: "Install every skill found in the source"
73
+ option :list, type: :boolean, desc: "Only list the skills discoverable in the source"
74
+ option :documents, type: :boolean,
75
+ desc: "Shorthand for #{DOCUMENTS_SOURCE} with #{DOCUMENT_SKILLS.join("/")}"
76
+ def install(source = nil)
77
+ wanted = Array(options[:skill])
78
+ if options[:documents]
79
+ source ||= DOCUMENTS_SOURCE
80
+ wanted = DOCUMENT_SKILLS.dup if wanted.empty?
81
+ end
82
+ if source.nil?
83
+ Rubino.ui.error("missing source — pass owner/repo, a git URL, or --documents")
84
+ return
85
+ end
86
+
87
+ installer = Skills::Installer.new
88
+ fetched = installer.fetch(source) do |checkout, sha|
89
+ found = installer.discover(checkout)
90
+ if found.empty?
91
+ Rubino.ui.warning("no skills found in #{source} (expected <name>/SKILL.md directories)")
92
+ elsif options[:list]
93
+ discovered_table(found)
94
+ else
95
+ install_selected(installer, found, wanted, checkout: checkout, source: source, commit: sha)
96
+ end
97
+ true
98
+ end
99
+ Rubino.ui.error("could not fetch #{source} — check the source name/URL and your network") if fetched.nil?
100
+ end
101
+
102
+ desc "update [NAME ...]", "Re-fetch installed skills from their recorded sources"
103
+ def update(*names)
104
+ installer = Skills::Installer.new
105
+ if installer.sources.empty?
106
+ Rubino.ui.info("No skills installed via `rubino skills install` yet.")
107
+ return
108
+ end
109
+
110
+ installer.update(names).each do |name, status|
111
+ case status
112
+ when :updated then Rubino.ui.success("Updated skill: #{name}")
113
+ when :up_to_date then Rubino.ui.info("#{name} is up to date.")
114
+ when :unknown then Rubino.ui.error("unknown skill: #{name} (not installed via `rubino skills install`)")
115
+ else Rubino.ui.error("could not update #{name} — fetch failed or the skill left its source")
116
+ end
117
+ end
118
+ end
119
+
120
+ desc "remove NAME", "Remove a skill installed via `rubino skills install`"
121
+ def remove(name)
122
+ installer = Skills::Installer.new
123
+ if installer.remove(name)
124
+ Rubino.ui.success("Removed skill: #{name}")
125
+ return
126
+ end
127
+
128
+ Rubino.ui.error("#{name} wasn't installed via `rubino skills install` (no provenance entry)")
129
+ dir = File.join(installer.skills_dir, name)
130
+ Rubino.ui.info("It exists at #{dir} — delete the directory manually.") if File.directory?(dir)
131
+ end
132
+
59
133
  private
60
134
 
61
135
  # The Status cell: enabled/disabled from the StateRepository (the same
@@ -68,6 +142,13 @@ module Rubino
68
142
  status
69
143
  end
70
144
 
145
+ # The Source cell: where an installed skill came from (provenance ledger),
146
+ # blank for built-in / hand-written skills.
147
+ def provenance(name, sources)
148
+ entry = sources[name]
149
+ entry ? "#{entry["source"]} @ #{entry["commit"].to_s[0, 7]}" : ""
150
+ end
151
+
71
152
  def toggle(name, enabled:)
72
153
  Rubino.ensure_database_ready!
73
154
  registry = Skills::Registry.trusted
@@ -80,6 +161,52 @@ module Rubino
80
161
 
81
162
  Rubino.ui.success("#{enabled ? "Enabled" : "Disabled"} skill: #{name}")
82
163
  end
164
+
165
+ # Resolves which of the discovered skills to install: explicit --skill
166
+ # names (all-or-nothing — a typo aborts rather than half-installing),
167
+ # --all, the only skill found, or an interactive pick. Off a TTY the
168
+ # picker returns nil and the multi-skill case degrades to the catalogue
169
+ # plus a --skill/--all hint.
170
+ def install_selected(installer, found, wanted, checkout:, source:, commit:)
171
+ chosen =
172
+ if wanted.any?
173
+ missing = wanted - found.map { |e| e[:name] }
174
+ unless missing.empty?
175
+ Rubino.ui.error("not found in #{source}: #{missing.join(", ")}")
176
+ Rubino.ui.info("Available: #{found.map { |e| e[:name] }.join(", ")}")
177
+ return
178
+ end
179
+ found.select { |e| wanted.include?(e[:name]) }
180
+ elsif options[:all] || found.size == 1
181
+ found
182
+ else
183
+ pick_one(found)
184
+ end
185
+ return if chosen.nil?
186
+
187
+ installer.install(chosen, checkout: checkout, source: source, commit: commit)
188
+ chosen.each { |e| Rubino.ui.success("Installed skill: #{e[:name]} (#{source} @ #{commit[0, 7]})") }
189
+ Rubino.ui.status("Installed into #{installer.skills_dir}")
190
+ end
191
+
192
+ # Multiple skills, none selected: print the catalogue and ask to pick
193
+ # (the same arrow-key picker /sessions resume uses). nil when there is
194
+ # no real terminal or the pick is cancelled.
195
+ def pick_one(found)
196
+ discovered_table(found)
197
+ picked = Rubino.ui.select("Install which skill?", found.map { |e| [e[:name], e] })
198
+ if picked.nil?
199
+ Rubino.ui.info("Multiple skills found — pass --skill NAME (repeatable) or --all.")
200
+ return nil
201
+ end
202
+
203
+ [picked]
204
+ end
205
+
206
+ def discovered_table(found)
207
+ rows = found.map { |e| [e[:name], e[:path], e[:description]] }
208
+ Rubino.ui.table(headers: %w[Name Path Description], rows: rows)
209
+ end
83
210
  end
84
211
  end
85
212
  end
@@ -299,9 +299,10 @@ module Rubino
299
299
  watch_loop(entry.id)
300
300
  end
301
301
 
302
- # Renders ONE watch frame: header + task + the recent: ring. Public-ish
303
- # snapshot shape reused per refresh tick. The recent ring is the registry's
304
- # bounded activity_log, plus the live last_activity as the trailing ● line.
302
+ # Renders ONE watch frame: header + task + the recent: ring + the live
303
+ # output: tail. Public-ish snapshot shape reused per refresh tick. The
304
+ # recent ring is the registry's bounded activity_log, plus the live
305
+ # last_activity as the trailing ● line.
305
306
  def render_agent_watch(entry)
306
307
  @ui.info("#{entry.id} #{agent_status_icon(entry.status)} · #{entry.subagent} · #{agent_elapsed(entry)}")
307
308
  @ui.info("task: #{truncate(entry.prompt, 120)}")
@@ -309,6 +310,25 @@ module Rubino
309
310
  Array(entry.activity_log).last(5).each { |line| @ui.info(" #{line}") }
310
311
  last = entry.last_activity.to_s
311
312
  @ui.info(" #{pastel.yellow("●")} #{last}") unless last.empty?
313
+ render_agent_output_tail(entry)
314
+ end
315
+
316
+ # #5 — the live output: block under the ring: the tail of the CURRENTLY
317
+ # RUNNING tool's streamed output (the registry's bounded output_tail,
318
+ # fed by the child's UI::SubagentView#tool_chunk and wiped at
319
+ # tool_finished), so a long shell call shows its lines as they print
320
+ # instead of a frozen frame. Renders nothing when no tool is mid-run or
321
+ # it hasn't produced output yet; the buffer's empty last slot just means
322
+ # the latest line is complete, so it is dropped, not rendered.
323
+ def render_agent_output_tail(entry)
324
+ lines = Array(entry.output_tail)
325
+ lines = lines[0..-2] if lines.last.to_s.empty?
326
+ return if lines.empty?
327
+
328
+ @ui.info("output:")
329
+ lines.last(Tools::BackgroundTasks::OUTPUT_TAIL_MAX).each do |line|
330
+ @ui.info(" #{pastel.dim("│")} #{truncate(line, 120)}")
331
+ end
312
332
  end
313
333
 
314
334
  # The live refresh loop for #watch_agent. Polls the registry and re-renders
@@ -26,7 +26,7 @@ module Rubino
26
26
  git gh rg jq curl wget
27
27
  ruby python3 node npm bundle
28
28
  docker psql sqlite3 redis-cli
29
- ffmpeg pandoc markitdown pdftotext tesseract
29
+ ffmpeg pandoc markitdown pdftotext tesseract soffice qpdf
30
30
  ].freeze
31
31
 
32
32
  class << self
@@ -114,7 +114,7 @@ module Rubino
114
114
  def linux_distro
115
115
  return nil unless File.readable?("/etc/os-release")
116
116
 
117
- pretty = File.read("/etc/os-release").lines.find { |l| l.start_with?("PRETTY_NAME=") }
117
+ pretty = File.read("/etc/os-release", encoding: "UTF-8").lines.find { |l| l.start_with?("PRETTY_NAME=") }
118
118
  pretty&.split("=", 2)&.last&.strip&.delete('"')
119
119
  rescue StandardError
120
120
  nil
@@ -22,7 +22,7 @@ module Rubino
22
22
  files = discover_files
23
23
  return nil if files.empty?
24
24
 
25
- files.map { |f| File.read(f) }.join("\n\n---\n\n")
25
+ files.map { |f| File.read(f, encoding: "UTF-8") }.join("\n\n---\n\n")
26
26
  end
27
27
 
28
28
  # Returns list of discovered context file paths
@@ -37,7 +37,7 @@ module Rubino
37
37
  def local_context(subdir)
38
38
  CONTEXT_FILES.filter_map do |filename|
39
39
  path = File.join(@base_path, subdir, filename)
40
- File.read(path) if File.exist?(path)
40
+ File.read(path, encoding: "UTF-8") if File.exist?(path)
41
41
  end.join("\n")
42
42
  end
43
43
  end
@@ -339,7 +339,7 @@ module Rubino
339
339
 
340
340
  def load_builtin_prompt(name)
341
341
  path = File.expand_path("../agent/prompts/#{name}.txt", __dir__)
342
- File.exist?(path) ? File.read(path).strip : nil
342
+ File.exist?(path) ? File.read(path, encoding: "UTF-8").strip : nil
343
343
  rescue StandardError
344
344
  nil
345
345
  end
@@ -0,0 +1,146 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "tmpdir"
5
+
6
+ module Rubino
7
+ module Skills
8
+ # Installs skills from git repositories into the user skills dir (#4) —
9
+ # the `rubino skills install/update/remove` backend. There is no
10
+ # marketplace and nothing is vendored in the gem: a source is just a repo
11
+ # (GitHub `owner/repo` shorthand or any git URL), shallow-cloned to a
12
+ # tmpdir, scanned for the registry's own `<name>/SKILL.md` layout, and the
13
+ # selected skill dirs are copied into `~/.rubino/skills` where the
14
+ # existing Registry discovers them like any hand-written skill.
15
+ #
16
+ # Provenance is recorded per installed skill in `<skills-dir>/.sources.json`
17
+ # (`name → {source, path, commit}`) so `update` can re-fetch from the
18
+ # recorded source and `remove` knows which dirs this mechanism owns. The
19
+ # dotfile name keeps it out of the registry's `*.md` / `*/SKILL.md` globs.
20
+ class Installer
21
+ SOURCES_FILE = ".sources.json"
22
+
23
+ # GitHub shorthand: bare `owner/repo` (one slash, no scheme/host).
24
+ GITHUB_SHORTHAND = %r{\A[\w.-]+/[\w.-]+\z}
25
+
26
+ attr_reader :skills_dir
27
+
28
+ def initialize(skills_dir: nil)
29
+ # The same resolved home the registry's "~/.rubino/skills" entry
30
+ # expands to (RUBINO_HOME → else ~/.rubino), so an install is
31
+ # discovered without any config change.
32
+ @skills_dir = skills_dir || File.join(Config::Loader.default_home_path, "skills")
33
+ end
34
+
35
+ # `owner/repo` → the GitHub URL; anything else is passed to git verbatim.
36
+ def self.url_for(source)
37
+ GITHUB_SHORTHAND.match?(source.to_s) ? "https://github.com/#{source}" : source.to_s
38
+ end
39
+
40
+ # Shallow-clones +source+ and yields (checkout_dir, head_sha); the tmp
41
+ # checkout is deleted when the block returns. Returns the block's value,
42
+ # or nil when the clone fails (unknown repo, no network — git's own
43
+ # stderr is left visible as the diagnostic). The ONE network touchpoint,
44
+ # so specs stub this method and never shell out.
45
+ def fetch(source)
46
+ Dir.mktmpdir("rubino-skills") do |dir|
47
+ return nil unless system("git", "clone", "--depth", "1", "--quiet",
48
+ self.class.url_for(source), dir, out: File::NULL)
49
+
50
+ sha = IO.popen(["git", "-C", dir, "rev-parse", "HEAD"], &:read).strip
51
+ yield dir, sha
52
+ end
53
+ end
54
+
55
+ # Skills discoverable in a checkout, as `{name:, path:, description:}`
56
+ # hashes (path = skill dir relative to the repo root). Recursive
57
+ # (`**/` + the registry's DIR_GLOB) so catalog repos that nest skills
58
+ # under a grouping dir are found too.
59
+ def discover(checkout)
60
+ Dir.glob(File.join("**", Registry::DIR_GLOB), base: checkout).sort.map do |rel|
61
+ dir = File.dirname(rel)
62
+ skill = Skill.new(path: File.join(checkout, rel))
63
+ { name: skill.name, path: dir, description: skill.description.to_s }
64
+ end
65
+ end
66
+
67
+ # Copies the discover-entries into the skills dir (replacing any prior
68
+ # copy of the same name) and records their provenance.
69
+ def install(entries, checkout:, source:, commit:)
70
+ FileUtils.mkdir_p(@skills_dir)
71
+ data = sources
72
+ entries.each do |entry|
73
+ dest = File.join(@skills_dir, entry[:name])
74
+ FileUtils.rm_rf(dest)
75
+ FileUtils.cp_r(File.join(checkout, entry[:path]), dest)
76
+ data[entry[:name]] = { "source" => source, "path" => entry[:path], "commit" => commit }
77
+ end
78
+ write_sources(data)
79
+ end
80
+
81
+ # Re-fetches +names+ (default: every recorded skill) from their recorded
82
+ # sources, one clone per distinct source. Returns name → :updated /
83
+ # :up_to_date / :failed (clone failed, or the skill's recorded path no
84
+ # longer holds a SKILL.md) / :unknown (no provenance entry).
85
+ def update(names = [])
86
+ data = sources
87
+ names = data.keys if names.empty?
88
+ results = {}
89
+ names.group_by { |name| data.dig(name, "source") }.each do |source, group|
90
+ next group.each { |name| results[name] = :unknown } if source.nil?
91
+
92
+ fetched = fetch(source) do |checkout, sha|
93
+ group.each { |name| results[name] = update_one(name, data[name], checkout, sha) }
94
+ write_sources(data)
95
+ true
96
+ end
97
+ group.each { |name| results[name] = :failed } unless fetched
98
+ end
99
+ results
100
+ end
101
+
102
+ # Deletes the skill dir + provenance entry. Returns false (nothing
103
+ # touched) for a skill without a provenance entry — this mechanism only
104
+ # removes what it installed.
105
+ def remove(name) # rubocop:disable Naming/PredicateMethod -- "did I remove anything", a mutator reporting what it did
106
+ data = sources
107
+ return false unless data.key?(name)
108
+
109
+ FileUtils.rm_rf(File.join(@skills_dir, name))
110
+ data.delete(name)
111
+ write_sources(data)
112
+ true
113
+ end
114
+
115
+ # The provenance ledger (empty hash when absent or unparseable).
116
+ def sources
117
+ path = File.join(@skills_dir, SOURCES_FILE)
118
+ File.file?(path) ? JSON.parse(File.read(path)) : {}
119
+ rescue JSON::ParserError
120
+ {}
121
+ end
122
+
123
+ private
124
+
125
+ # Re-copies one skill from a fresh checkout, mutating its ledger entry's
126
+ # commit in place (the caller persists the ledger once per source).
127
+ def update_one(name, entry, checkout, sha)
128
+ return :up_to_date if entry["commit"] == sha
129
+
130
+ src = File.join(checkout, entry["path"])
131
+ return :failed unless File.file?(File.join(src, "SKILL.md"))
132
+
133
+ dest = File.join(@skills_dir, name)
134
+ FileUtils.rm_rf(dest)
135
+ FileUtils.cp_r(src, dest)
136
+ entry["commit"] = sha
137
+ :updated
138
+ end
139
+
140
+ def write_sources(data)
141
+ FileUtils.mkdir_p(@skills_dir)
142
+ File.write(File.join(@skills_dir, SOURCES_FILE), JSON.pretty_generate(data))
143
+ end
144
+ end
145
+ end
146
+ end
@@ -11,6 +11,12 @@ module Rubino
11
11
  # Directory skills: <dir>/<name>/SKILL.md (Claude skill layout).
12
12
  DIR_GLOB = File.join("*", "SKILL.md")
13
13
 
14
+ # Agent-neutral skill dirs (the emerging `npx skills` / Gemini CLI /
15
+ # goose convention): discovered in ADDITION to the configured rubino
16
+ # paths, scanned before them so a rubino-path skill of the same name
17
+ # wins (lowest precedence). No behavior change when the dirs are absent.
18
+ AGENT_NEUTRAL_PATHS = [".agents/skills", "~/.agents/skills"].freeze
19
+
14
20
  # Skills shipped *inside the gem* (skills/<name>/SKILL.md at the gem
15
21
  # root, packaged via the gemspec's git-ls-files list). These are
16
22
  # ALWAYS discovered — they don't depend on the user's skills.paths
@@ -176,6 +182,10 @@ module Rubino
176
182
  ".rubino/skills",
177
183
  "~/.rubino/skills"
178
184
  ]
185
+ # Prepended (not appended) so the rubino paths override them on a name
186
+ # collision, and BEFORE the trust filter so the project-local
187
+ # `.agents/skills` is gated exactly like `.rubino/skills`.
188
+ paths = AGENT_NEUTRAL_PATHS + paths
179
189
  unless @include_project_local
180
190
  # Untrusted primary root: drop the project-local (cwd-relative) skill
181
191
  # dirs, keeping only absolute / home (~) paths the user controls.
@@ -61,7 +61,7 @@ module Rubino
61
61
  resolved = resolve_within_dir(relative_path)
62
62
  return nil unless resolved && File.file?(resolved)
63
63
 
64
- File.read(resolved)
64
+ File.read(resolved, encoding: "UTF-8")
65
65
  rescue Errno::ENOENT, Errno::EACCES
66
66
  nil
67
67
  end
@@ -128,7 +128,7 @@ module Rubino
128
128
  end
129
129
 
130
130
  def parse_frontmatter!
131
- raw = File.read(@path)
131
+ raw = File.read(@path, encoding: "UTF-8")
132
132
 
133
133
  if raw.start_with?("---")
134
134
  parts = raw.split("---", 3)
@@ -160,7 +160,7 @@ module Rubino
160
160
  end
161
161
 
162
162
  def load_content
163
- raw = File.read(@path)
163
+ raw = File.read(@path, encoding: "UTF-8")
164
164
 
165
165
  if raw.start_with?("---")
166
166
  parts = raw.split("---", 3)
@@ -38,8 +38,11 @@ module Rubino
38
38
  # #record_tool_started / #record_tool_finished) under the registry mutex
39
39
  # and read by the parent renderer (UI::SubagentCards) and
40
40
  # the /agents drill-in. activity_log is a bounded ring of the last few
41
- # `✓ verb · hint` lines for the live drill-in; nothing is persisted (it
42
- # dies with the process, like the rest of the registry).
41
+ # `✓ verb · hint` lines for the live drill-in; output_tail is the bounded
42
+ # line buffer of the CURRENTLY RUNNING tool's streamed output (fed by
43
+ # #record_tool_output, wiped at #record_tool_finished) that the drill-in's
44
+ # output: block tails (#5). Nothing is persisted (it dies with the
45
+ # process, like the rest of the registry).
43
46
  #
44
47
  # approval_gate / approval_question / approval_command are the
45
48
  # Option-2 approval-surfacing state: when a background child's tool needs
@@ -49,7 +52,7 @@ module Rubino
49
52
  Entry = Struct.new(
50
53
  :id, :subagent, :prompt, :status, :result, :error,
51
54
  :thread, :runner, :started_at, :finished_at,
52
- :last_activity, :tool_count, :activity_log,
55
+ :last_activity, :tool_count, :activity_log, :output_tail,
53
56
  :approval_gate, :approval_id, :approval_question, :approval_command,
54
57
  # Parent->child steer (the `/agents <id> steer "..."` note). Wired into
55
58
  # the child Loop as its Interaction::InputQueue (the SAME turn-boundary
@@ -94,6 +97,13 @@ module Rubino
94
97
  # How many recent activity lines the drill-in shows (the live `recent:` ring).
95
98
  ACTIVITY_LOG_MAX = 6
96
99
 
100
+ # Bounds for the live output tail (#5): how many COMPLETE lines the
101
+ # drill-in's output: block shows (the buffer keeps one extra slot for the
102
+ # in-flight partial line), and the byte cap per buffered line so a
103
+ # newline-free stream can't grow a line unbounded.
104
+ OUTPUT_TAIL_MAX = 6
105
+ OUTPUT_TAIL_LINE_MAX = 200
106
+
97
107
  class << self
98
108
  def instance
99
109
  @instance ||= new
@@ -219,7 +229,8 @@ module Rubino
219
229
  # Records a child tool FINISHING: appends a terse line to the bounded
220
230
  # activity ring the live drill-in (#71) tails. Keeps the last
221
231
  # ACTIVITY_LOG_MAX entries so the ring never grows unbounded for a
222
- # read-heavy child.
232
+ # read-heavy child. Also wipes the live output tail — it belongs to the
233
+ # tool that just finished, so the drill-in's output: block clears (#5).
223
234
  def record_tool_finished(id, line)
224
235
  @mutex.synchronize do
225
236
  entry = @entries[id]
@@ -228,6 +239,26 @@ module Rubino
228
239
  log = (entry.activity_log ||= [])
229
240
  log << line.to_s
230
241
  log.shift while log.size > ACTIVITY_LOG_MAX
242
+ entry.output_tail = nil
243
+ end
244
+ end
245
+
246
+ # Records a streamed chunk of the CURRENTLY RUNNING tool's output (#5):
247
+ # splits on newlines into a bounded line buffer whose LAST slot carries
248
+ # the in-flight partial line, so the /agents drill-in can tail it live.
249
+ # Called from UI::SubagentView#tool_chunk on the CHILD thread, so it MUST
250
+ # take the mutex like the other record_* writers. No-op for an unknown id.
251
+ def record_tool_output(id, chunk)
252
+ @mutex.synchronize do
253
+ entry = @entries[id]
254
+ return unless entry
255
+
256
+ tail = (entry.output_tail ||= [""])
257
+ chunk.to_s.each_line do |line|
258
+ tail[-1] = "#{tail[-1]}#{line.chomp}"[0, OUTPUT_TAIL_LINE_MAX]
259
+ tail << "" if line.end_with?("\n")
260
+ end
261
+ tail.shift while tail.size > OUTPUT_TAIL_MAX + 1
231
262
  end
232
263
  end
233
264
 
@@ -28,7 +28,8 @@ module Rubino
28
28
  # single in-place line per subagent (`▸ sa_… · explore · running · N tools ·
29
29
  # Ns · <last_activity>`) that updates without scrolling — see UI::CLI
30
30
  # #set_subagent_cards / UI::SubagentCards. The /agents <id> drill-in tails the
31
- # same registry ring for the live recent: list (#71).
31
+ # same registry ring for the live recent: list (#71) and the entry's
32
+ # output_tail — fed by #tool_chunk — for the live output: block (#5).
32
33
  #
33
34
  # The view is wired with the entry id at construction (TaskTool builds it per
34
35
  # background run). With no id (legacy/foreground synchronous path, tests) it
@@ -124,10 +125,20 @@ module Rubino
124
125
  end
125
126
  end
126
127
 
127
- # tool_body / tool_chunk: the child's tool previews/streamed chunks. Kept
128
- # quiet to stay low-noise the start/finish rows already say what ran.
128
+ # Card mode: append the streamed chunk to the entry's bounded output tail
129
+ # the live output: block the /agents <id> watch tails while THIS tool runs
130
+ # (#5). Registry-only, NO card repaint: a chatty shell streams a chunk per
131
+ # line and repainting the cards per chunk would flood the parent terminal
132
+ # (the watch drill-in re-reads the tail on its own tick). Legacy mode
133
+ # stays quiet (the start/finish rows already say what ran).
134
+ def tool_chunk(_name, chunk)
135
+ Tools::BackgroundTasks.instance.record_tool_output(@entry_id, chunk) if card_mode?
136
+ end
137
+
138
+ # tool_body: the END-OF-CALL preview of a NON-streaming tool (the executor
139
+ # skips it when the tool streamed via #tool_chunk). Useless for the live
140
+ # tail — tool_finished wipes the buffer right after — so it stays quiet.
129
141
  def tool_body(_text, kind: :plain); end
130
- def tool_chunk(_name, _chunk); end
131
142
 
132
143
  # --- Suppressed: the child's prose / token stream ---------------------
133
144
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Rubino
4
- VERSION = "0.3.0"
4
+ VERSION = "0.4.0"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rubino-agent
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jhon Rojas
@@ -733,6 +733,7 @@ files:
733
733
  - lib/rubino/session/repository.rb
734
734
  - lib/rubino/session/store.rb
735
735
  - lib/rubino/session/summary_store.rb
736
+ - lib/rubino/skills/installer.rb
736
737
  - lib/rubino/skills/prompt_index.rb
737
738
  - lib/rubino/skills/registry.rb
738
739
  - lib/rubino/skills/skill.rb