rubyn-code 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/LICENSE +21 -0
- data/README.md +620 -0
- data/db/migrations/000_create_schema_migrations.sql +4 -0
- data/db/migrations/001_create_sessions.sql +16 -0
- data/db/migrations/002_create_messages.sql +16 -0
- data/db/migrations/003_create_tasks.sql +17 -0
- data/db/migrations/004_create_task_dependencies.sql +8 -0
- data/db/migrations/005_create_memories.sql +44 -0
- data/db/migrations/006_create_cost_records.sql +16 -0
- data/db/migrations/007_create_hooks.sql +12 -0
- data/db/migrations/008_create_skills_cache.sql +8 -0
- data/db/migrations/009_create_teams.sql +27 -0
- data/db/migrations/010_create_instincts.sql +15 -0
- data/exe/rubyn-code +6 -0
- data/lib/rubyn_code/agent/conversation.rb +193 -0
- data/lib/rubyn_code/agent/loop.rb +517 -0
- data/lib/rubyn_code/agent/loop_detector.rb +78 -0
- data/lib/rubyn_code/auth/oauth.rb +174 -0
- data/lib/rubyn_code/auth/server.rb +126 -0
- data/lib/rubyn_code/auth/token_store.rb +153 -0
- data/lib/rubyn_code/autonomous/daemon.rb +233 -0
- data/lib/rubyn_code/autonomous/idle_poller.rb +111 -0
- data/lib/rubyn_code/autonomous/task_claimer.rb +100 -0
- data/lib/rubyn_code/background/job.rb +19 -0
- data/lib/rubyn_code/background/notifier.rb +44 -0
- data/lib/rubyn_code/background/worker.rb +146 -0
- data/lib/rubyn_code/cli/app.rb +118 -0
- data/lib/rubyn_code/cli/input_handler.rb +79 -0
- data/lib/rubyn_code/cli/renderer.rb +205 -0
- data/lib/rubyn_code/cli/repl.rb +519 -0
- data/lib/rubyn_code/cli/spinner.rb +100 -0
- data/lib/rubyn_code/cli/stream_formatter.rb +149 -0
- data/lib/rubyn_code/config/defaults.rb +43 -0
- data/lib/rubyn_code/config/project_config.rb +120 -0
- data/lib/rubyn_code/config/settings.rb +127 -0
- data/lib/rubyn_code/context/auto_compact.rb +81 -0
- data/lib/rubyn_code/context/compactor.rb +89 -0
- data/lib/rubyn_code/context/manager.rb +91 -0
- data/lib/rubyn_code/context/manual_compact.rb +87 -0
- data/lib/rubyn_code/context/micro_compact.rb +135 -0
- data/lib/rubyn_code/db/connection.rb +176 -0
- data/lib/rubyn_code/db/migrator.rb +146 -0
- data/lib/rubyn_code/db/schema.rb +106 -0
- data/lib/rubyn_code/hooks/built_in.rb +124 -0
- data/lib/rubyn_code/hooks/registry.rb +99 -0
- data/lib/rubyn_code/hooks/runner.rb +88 -0
- data/lib/rubyn_code/hooks/user_hooks.rb +90 -0
- data/lib/rubyn_code/learning/extractor.rb +191 -0
- data/lib/rubyn_code/learning/injector.rb +138 -0
- data/lib/rubyn_code/learning/instinct.rb +172 -0
- data/lib/rubyn_code/llm/client.rb +218 -0
- data/lib/rubyn_code/llm/message_builder.rb +116 -0
- data/lib/rubyn_code/llm/streaming.rb +203 -0
- data/lib/rubyn_code/mcp/client.rb +139 -0
- data/lib/rubyn_code/mcp/config.rb +83 -0
- data/lib/rubyn_code/mcp/sse_transport.rb +225 -0
- data/lib/rubyn_code/mcp/stdio_transport.rb +196 -0
- data/lib/rubyn_code/mcp/tool_bridge.rb +164 -0
- data/lib/rubyn_code/memory/models.rb +62 -0
- data/lib/rubyn_code/memory/search.rb +181 -0
- data/lib/rubyn_code/memory/session_persistence.rb +194 -0
- data/lib/rubyn_code/memory/store.rb +199 -0
- data/lib/rubyn_code/observability/budget_enforcer.rb +159 -0
- data/lib/rubyn_code/observability/cost_calculator.rb +61 -0
- data/lib/rubyn_code/observability/models.rb +29 -0
- data/lib/rubyn_code/observability/token_counter.rb +42 -0
- data/lib/rubyn_code/observability/usage_reporter.rb +140 -0
- data/lib/rubyn_code/output/diff_renderer.rb +212 -0
- data/lib/rubyn_code/output/formatter.rb +120 -0
- data/lib/rubyn_code/permissions/deny_list.rb +49 -0
- data/lib/rubyn_code/permissions/policy.rb +59 -0
- data/lib/rubyn_code/permissions/prompter.rb +80 -0
- data/lib/rubyn_code/permissions/tier.rb +22 -0
- data/lib/rubyn_code/protocols/interrupt_handler.rb +95 -0
- data/lib/rubyn_code/protocols/plan_approval.rb +67 -0
- data/lib/rubyn_code/protocols/shutdown_handshake.rb +109 -0
- data/lib/rubyn_code/skills/catalog.rb +70 -0
- data/lib/rubyn_code/skills/document.rb +80 -0
- data/lib/rubyn_code/skills/loader.rb +57 -0
- data/lib/rubyn_code/sub_agents/runner.rb +168 -0
- data/lib/rubyn_code/sub_agents/summarizer.rb +57 -0
- data/lib/rubyn_code/tasks/dag.rb +208 -0
- data/lib/rubyn_code/tasks/manager.rb +212 -0
- data/lib/rubyn_code/tasks/models.rb +31 -0
- data/lib/rubyn_code/teams/mailbox.rb +128 -0
- data/lib/rubyn_code/teams/manager.rb +175 -0
- data/lib/rubyn_code/teams/teammate.rb +38 -0
- data/lib/rubyn_code/tools/background_run.rb +41 -0
- data/lib/rubyn_code/tools/base.rb +84 -0
- data/lib/rubyn_code/tools/bash.rb +81 -0
- data/lib/rubyn_code/tools/bundle_add.rb +53 -0
- data/lib/rubyn_code/tools/bundle_install.rb +41 -0
- data/lib/rubyn_code/tools/compact.rb +57 -0
- data/lib/rubyn_code/tools/db_migrate.rb +52 -0
- data/lib/rubyn_code/tools/edit_file.rb +49 -0
- data/lib/rubyn_code/tools/executor.rb +62 -0
- data/lib/rubyn_code/tools/git_commit.rb +97 -0
- data/lib/rubyn_code/tools/git_diff.rb +61 -0
- data/lib/rubyn_code/tools/git_log.rb +59 -0
- data/lib/rubyn_code/tools/git_status.rb +59 -0
- data/lib/rubyn_code/tools/glob.rb +44 -0
- data/lib/rubyn_code/tools/grep.rb +81 -0
- data/lib/rubyn_code/tools/load_skill.rb +41 -0
- data/lib/rubyn_code/tools/memory_search.rb +77 -0
- data/lib/rubyn_code/tools/memory_write.rb +52 -0
- data/lib/rubyn_code/tools/rails_generate.rb +54 -0
- data/lib/rubyn_code/tools/read_file.rb +38 -0
- data/lib/rubyn_code/tools/read_inbox.rb +64 -0
- data/lib/rubyn_code/tools/registry.rb +48 -0
- data/lib/rubyn_code/tools/review_pr.rb +145 -0
- data/lib/rubyn_code/tools/run_specs.rb +75 -0
- data/lib/rubyn_code/tools/schema.rb +59 -0
- data/lib/rubyn_code/tools/send_message.rb +53 -0
- data/lib/rubyn_code/tools/spawn_agent.rb +154 -0
- data/lib/rubyn_code/tools/spawn_teammate.rb +168 -0
- data/lib/rubyn_code/tools/task.rb +148 -0
- data/lib/rubyn_code/tools/web_fetch.rb +108 -0
- data/lib/rubyn_code/tools/web_search.rb +196 -0
- data/lib/rubyn_code/tools/write_file.rb +30 -0
- data/lib/rubyn_code/version.rb +5 -0
- data/lib/rubyn_code.rb +203 -0
- data/skills/code_quality/fits_in_your_head.md +189 -0
- data/skills/code_quality/naming_conventions.md +213 -0
- data/skills/code_quality/null_object.md +205 -0
- data/skills/code_quality/technical_debt.md +135 -0
- data/skills/code_quality/value_objects.md +216 -0
- data/skills/code_quality/yagni.md +176 -0
- data/skills/design_patterns/adapter.md +191 -0
- data/skills/design_patterns/bridge_memento_visitor.md +254 -0
- data/skills/design_patterns/builder.md +158 -0
- data/skills/design_patterns/command.md +126 -0
- data/skills/design_patterns/composite.md +147 -0
- data/skills/design_patterns/decorator.md +204 -0
- data/skills/design_patterns/facade.md +133 -0
- data/skills/design_patterns/factory_method.md +169 -0
- data/skills/design_patterns/iterator.md +116 -0
- data/skills/design_patterns/mediator.md +133 -0
- data/skills/design_patterns/observer.md +177 -0
- data/skills/design_patterns/proxy.md +140 -0
- data/skills/design_patterns/singleton.md +124 -0
- data/skills/design_patterns/state.md +207 -0
- data/skills/design_patterns/strategy.md +127 -0
- data/skills/design_patterns/template_method.md +173 -0
- data/skills/gems/devise.md +365 -0
- data/skills/gems/dry_rb.md +186 -0
- data/skills/gems/factory_bot.md +268 -0
- data/skills/gems/faraday.md +263 -0
- data/skills/gems/graphql_ruby.md +514 -0
- data/skills/gems/pundit.md +446 -0
- data/skills/gems/redis.md +219 -0
- data/skills/gems/rubocop.md +257 -0
- data/skills/gems/sidekiq.md +360 -0
- data/skills/gems/stripe.md +224 -0
- data/skills/minitest/assertions.md +185 -0
- data/skills/minitest/fixtures.md +238 -0
- data/skills/minitest/integration_tests.md +210 -0
- data/skills/minitest/mailers_and_jobs.md +218 -0
- data/skills/minitest/mocking_stubbing.md +202 -0
- data/skills/minitest/service_tests_and_performance.md +246 -0
- data/skills/minitest/structure_and_conventions.md +169 -0
- data/skills/minitest/system_tests.md +237 -0
- data/skills/rails/action_cable.md +160 -0
- data/skills/rails/active_record_basics.md +174 -0
- data/skills/rails/active_storage.md +242 -0
- data/skills/rails/api_design.md +212 -0
- data/skills/rails/associations.md +182 -0
- data/skills/rails/background_jobs.md +212 -0
- data/skills/rails/caching.md +158 -0
- data/skills/rails/callbacks.md +135 -0
- data/skills/rails/concerns_controllers.md +218 -0
- data/skills/rails/concerns_models.md +280 -0
- data/skills/rails/controllers.md +190 -0
- data/skills/rails/engines.md +201 -0
- data/skills/rails/form_objects.md +168 -0
- data/skills/rails/hotwire.md +229 -0
- data/skills/rails/internationalization.md +192 -0
- data/skills/rails/logging.md +198 -0
- data/skills/rails/mailers.md +180 -0
- data/skills/rails/migrations.md +200 -0
- data/skills/rails/multitenancy.md +207 -0
- data/skills/rails/n_plus_one.md +151 -0
- data/skills/rails/presenters.md +244 -0
- data/skills/rails/query_objects.md +177 -0
- data/skills/rails/routing.md +194 -0
- data/skills/rails/scopes.md +187 -0
- data/skills/rails/security.md +233 -0
- data/skills/rails/serializers.md +243 -0
- data/skills/rails/service_objects.md +184 -0
- data/skills/rails/testing_strategy.md +258 -0
- data/skills/rails/validations.md +206 -0
- data/skills/refactoring/code_smells.md +251 -0
- data/skills/refactoring/command_query_separation.md +166 -0
- data/skills/refactoring/encapsulate_collection.md +125 -0
- data/skills/refactoring/extract_class.md +138 -0
- data/skills/refactoring/extract_method.md +185 -0
- data/skills/refactoring/replace_conditional.md +211 -0
- data/skills/refactoring/value_objects.md +246 -0
- data/skills/rspec/build_stubbed.md +199 -0
- data/skills/rspec/factory_design.md +206 -0
- data/skills/rspec/let_vs_let_bang.md +161 -0
- data/skills/rspec/mocking_stubbing.md +209 -0
- data/skills/rspec/request_specs.md +212 -0
- data/skills/rspec/service_specs.md +262 -0
- data/skills/rspec/shared_examples.md +244 -0
- data/skills/rspec/system_specs.md +286 -0
- data/skills/rspec/test_performance.md +215 -0
- data/skills/ruby/blocks_procs_lambdas.md +204 -0
- data/skills/ruby/classes.md +155 -0
- data/skills/ruby/concurrency.md +194 -0
- data/skills/ruby/data_struct_openstruct.md +158 -0
- data/skills/ruby/debugging_profiling.md +204 -0
- data/skills/ruby/enumerable_patterns.md +168 -0
- data/skills/ruby/exception_handling.md +199 -0
- data/skills/ruby/file_io.md +217 -0
- data/skills/ruby/hashes.md +195 -0
- data/skills/ruby/metaprogramming.md +170 -0
- data/skills/ruby/modules.md +210 -0
- data/skills/ruby/pattern_matching.md +177 -0
- data/skills/ruby/regular_expressions.md +166 -0
- data/skills/ruby/result_objects.md +200 -0
- data/skills/ruby/strings.md +177 -0
- data/skills/ruby_project/bundler_dependencies.md +181 -0
- data/skills/ruby_project/cli_tools.md +224 -0
- data/skills/ruby_project/rake_tasks.md +146 -0
- data/skills/ruby_project/structure.md +261 -0
- data/skills/sinatra/application_structure.md +241 -0
- data/skills/sinatra/middleware_and_deployment.md +221 -0
- data/skills/sinatra/testing.md +233 -0
- data/skills/solid/dependency_inversion.md +195 -0
- data/skills/solid/interface_segregation.md +237 -0
- data/skills/solid/liskov_substitution.md +263 -0
- data/skills/solid/open_closed.md +212 -0
- data/skills/solid/single_responsibility.md +183 -0
- metadata +397 -0
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "securerandom"
|
|
5
|
+
require_relative "teammate"
|
|
6
|
+
|
|
7
|
+
module RubynCode
|
|
8
|
+
module Teams
|
|
9
|
+
# CRUD manager for teammates backed by SQLite.
|
|
10
|
+
#
|
|
11
|
+
# Provides lifecycle management for agent teammates: spawning,
|
|
12
|
+
# listing, status updates, and removal.
|
|
13
|
+
class Manager
|
|
14
|
+
# @param db [DB::Connection] the database connection
|
|
15
|
+
# @param mailbox [Mailbox] the team mailbox for inter-agent messaging
|
|
16
|
+
def initialize(db, mailbox:)
|
|
17
|
+
@db = db
|
|
18
|
+
@mailbox = mailbox
|
|
19
|
+
ensure_table!
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Creates a new teammate record.
|
|
23
|
+
#
|
|
24
|
+
# @param name [String] unique teammate name
|
|
25
|
+
# @param role [String] the teammate's role description
|
|
26
|
+
# @param persona [String, nil] optional persona prompt
|
|
27
|
+
# @param model [String, nil] optional LLM model override
|
|
28
|
+
# @return [Teammate] the newly created teammate
|
|
29
|
+
# @raise [Error] if a teammate with the given name already exists
|
|
30
|
+
def spawn(name:, role:, persona: nil, model: nil)
|
|
31
|
+
existing = get(name)
|
|
32
|
+
raise Error, "Teammate '#{name}' already exists" if existing
|
|
33
|
+
|
|
34
|
+
id = SecureRandom.uuid
|
|
35
|
+
now = Time.now.utc.iso8601
|
|
36
|
+
metadata_json = JSON.generate({})
|
|
37
|
+
|
|
38
|
+
@db.execute(
|
|
39
|
+
<<~SQL,
|
|
40
|
+
INSERT INTO teammates (id, name, role, persona, model, status, metadata, created_at)
|
|
41
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
42
|
+
SQL
|
|
43
|
+
[id, name, role, persona, model, "idle", metadata_json, now]
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
Teammate.new(
|
|
47
|
+
id: id,
|
|
48
|
+
name: name,
|
|
49
|
+
role: role,
|
|
50
|
+
persona: persona,
|
|
51
|
+
model: model,
|
|
52
|
+
status: "idle",
|
|
53
|
+
metadata: {},
|
|
54
|
+
created_at: now
|
|
55
|
+
)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Returns all teammates.
|
|
59
|
+
#
|
|
60
|
+
# @return [Array<Teammate>]
|
|
61
|
+
def list
|
|
62
|
+
rows = @db.query("SELECT * FROM teammates ORDER BY created_at ASC").to_a
|
|
63
|
+
rows.map { |row| row_to_teammate(row) }
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Finds a teammate by name.
|
|
67
|
+
#
|
|
68
|
+
# @param name [String]
|
|
69
|
+
# @return [Teammate, nil]
|
|
70
|
+
def get(name)
|
|
71
|
+
rows = @db.query("SELECT * FROM teammates WHERE name = ? LIMIT 1", [name]).to_a
|
|
72
|
+
return nil if rows.empty?
|
|
73
|
+
|
|
74
|
+
row_to_teammate(rows.first)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Updates a teammate's status.
|
|
78
|
+
#
|
|
79
|
+
# @param name [String]
|
|
80
|
+
# @param status [String] one of "idle", "active", "offline"
|
|
81
|
+
# @return [void]
|
|
82
|
+
# @raise [ArgumentError] if the status is invalid
|
|
83
|
+
# @raise [Error] if the teammate is not found
|
|
84
|
+
def update_status(name, status)
|
|
85
|
+
unless VALID_STATUSES.include?(status)
|
|
86
|
+
raise ArgumentError, "Invalid status '#{status}'. Must be one of: #{VALID_STATUSES.join(', ')}"
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
teammate = get(name)
|
|
90
|
+
raise Error, "Teammate '#{name}' not found" unless teammate
|
|
91
|
+
|
|
92
|
+
@db.execute(
|
|
93
|
+
"UPDATE teammates SET status = ? WHERE name = ?",
|
|
94
|
+
[status, name]
|
|
95
|
+
)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Removes a teammate by name.
|
|
99
|
+
#
|
|
100
|
+
# @param name [String]
|
|
101
|
+
# @return [void]
|
|
102
|
+
# @raise [Error] if the teammate is not found
|
|
103
|
+
def remove(name)
|
|
104
|
+
teammate = get(name)
|
|
105
|
+
raise Error, "Teammate '#{name}' not found" unless teammate
|
|
106
|
+
|
|
107
|
+
@db.execute("DELETE FROM teammates WHERE name = ?", [name])
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Returns all teammates with status "active".
|
|
111
|
+
#
|
|
112
|
+
# @return [Array<Teammate>]
|
|
113
|
+
def active_teammates
|
|
114
|
+
rows = @db.query(
|
|
115
|
+
"SELECT * FROM teammates WHERE status = ? ORDER BY created_at ASC",
|
|
116
|
+
["active"]
|
|
117
|
+
).to_a
|
|
118
|
+
rows.map { |row| row_to_teammate(row) }
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
private
|
|
122
|
+
|
|
123
|
+
# Converts a database row hash to a Teammate value object.
|
|
124
|
+
#
|
|
125
|
+
# @param row [Hash]
|
|
126
|
+
# @return [Teammate]
|
|
127
|
+
def row_to_teammate(row)
|
|
128
|
+
metadata = parse_metadata(row["metadata"])
|
|
129
|
+
|
|
130
|
+
Teammate.new(
|
|
131
|
+
id: row["id"],
|
|
132
|
+
name: row["name"],
|
|
133
|
+
role: row["role"],
|
|
134
|
+
persona: row["persona"],
|
|
135
|
+
model: row["model"],
|
|
136
|
+
status: row["status"],
|
|
137
|
+
metadata: metadata,
|
|
138
|
+
created_at: row["created_at"]
|
|
139
|
+
)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Safely parses JSON metadata, returning an empty hash on failure.
|
|
143
|
+
#
|
|
144
|
+
# @param raw [String, nil]
|
|
145
|
+
# @return [Hash]
|
|
146
|
+
def parse_metadata(raw)
|
|
147
|
+
return {} if raw.nil? || raw.empty?
|
|
148
|
+
|
|
149
|
+
JSON.parse(raw, symbolize_names: true)
|
|
150
|
+
rescue JSON::ParserError
|
|
151
|
+
{}
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Creates the teammates table if it does not already exist.
|
|
155
|
+
def ensure_table!
|
|
156
|
+
@db.execute(<<~SQL)
|
|
157
|
+
CREATE TABLE IF NOT EXISTS teammates (
|
|
158
|
+
id TEXT PRIMARY KEY,
|
|
159
|
+
name TEXT NOT NULL UNIQUE,
|
|
160
|
+
role TEXT NOT NULL,
|
|
161
|
+
persona TEXT,
|
|
162
|
+
model TEXT,
|
|
163
|
+
status TEXT NOT NULL DEFAULT 'idle',
|
|
164
|
+
metadata TEXT NOT NULL DEFAULT '{}',
|
|
165
|
+
created_at TEXT NOT NULL
|
|
166
|
+
)
|
|
167
|
+
SQL
|
|
168
|
+
|
|
169
|
+
@db.execute(<<~SQL)
|
|
170
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_teammates_name ON teammates (name)
|
|
171
|
+
SQL
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubynCode
|
|
4
|
+
module Teams
|
|
5
|
+
# Immutable value object representing a teammate in an agent team.
|
|
6
|
+
#
|
|
7
|
+
# Status values: "idle", "active", "offline"
|
|
8
|
+
VALID_STATUSES = %w[idle active offline].freeze
|
|
9
|
+
|
|
10
|
+
Teammate = Data.define(
|
|
11
|
+
:id, :name, :role, :persona, :model, :status, :metadata, :created_at
|
|
12
|
+
) do
|
|
13
|
+
|
|
14
|
+
# @return [Boolean]
|
|
15
|
+
def idle? = status == "idle"
|
|
16
|
+
|
|
17
|
+
# @return [Boolean]
|
|
18
|
+
def active? = status == "active"
|
|
19
|
+
|
|
20
|
+
# @return [Boolean]
|
|
21
|
+
def offline? = status == "offline"
|
|
22
|
+
|
|
23
|
+
# @return [Hash]
|
|
24
|
+
def to_h
|
|
25
|
+
{
|
|
26
|
+
id: id,
|
|
27
|
+
name: name,
|
|
28
|
+
role: role,
|
|
29
|
+
persona: persona,
|
|
30
|
+
model: model,
|
|
31
|
+
status: status,
|
|
32
|
+
metadata: metadata,
|
|
33
|
+
created_at: created_at
|
|
34
|
+
}
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base"
|
|
4
|
+
require_relative "registry"
|
|
5
|
+
|
|
6
|
+
module RubynCode
|
|
7
|
+
module Tools
|
|
8
|
+
class BackgroundRun < Base
|
|
9
|
+
TOOL_NAME = "background_run"
|
|
10
|
+
DESCRIPTION = "Run a command in the background (test suites, builds, deploys). " \
|
|
11
|
+
"Returns immediately with a job ID. Results are delivered automatically " \
|
|
12
|
+
"before your next LLM call."
|
|
13
|
+
PARAMETERS = {
|
|
14
|
+
command: {
|
|
15
|
+
type: :string,
|
|
16
|
+
description: "The shell command to run in the background",
|
|
17
|
+
required: true
|
|
18
|
+
},
|
|
19
|
+
timeout: {
|
|
20
|
+
type: :integer,
|
|
21
|
+
description: "Timeout in seconds (default: 300)",
|
|
22
|
+
required: false
|
|
23
|
+
}
|
|
24
|
+
}.freeze
|
|
25
|
+
RISK_LEVEL = :execute
|
|
26
|
+
|
|
27
|
+
attr_writer :background_worker
|
|
28
|
+
|
|
29
|
+
def execute(command:, timeout: 300)
|
|
30
|
+
unless @background_worker
|
|
31
|
+
return "Error: Background worker not available. Use bash tool instead."
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
job_id = @background_worker.run(command, timeout: timeout)
|
|
35
|
+
"Background job started: #{job_id}\nCommand: #{command}\nTimeout: #{timeout}s\nResults will appear automatically when complete."
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
Registry.register(BackgroundRun)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubynCode
|
|
4
|
+
module Tools
|
|
5
|
+
class Base
|
|
6
|
+
TOOL_NAME = ""
|
|
7
|
+
DESCRIPTION = ""
|
|
8
|
+
PARAMETERS = {}.freeze
|
|
9
|
+
RISK_LEVEL = :read
|
|
10
|
+
REQUIRES_CONFIRMATION = false
|
|
11
|
+
|
|
12
|
+
class << self
|
|
13
|
+
def tool_name
|
|
14
|
+
const_get(:TOOL_NAME)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def description
|
|
18
|
+
const_get(:DESCRIPTION)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def parameters
|
|
22
|
+
const_get(:PARAMETERS)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def risk_level
|
|
26
|
+
const_get(:RISK_LEVEL)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def requires_confirmation?
|
|
30
|
+
const_get(:REQUIRES_CONFIRMATION)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def to_schema
|
|
34
|
+
{
|
|
35
|
+
name: tool_name,
|
|
36
|
+
description: description,
|
|
37
|
+
input_schema: Schema.build(parameters)
|
|
38
|
+
}
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
attr_reader :project_root
|
|
43
|
+
|
|
44
|
+
def initialize(project_root:)
|
|
45
|
+
@project_root = File.expand_path(project_root)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def execute(**params)
|
|
49
|
+
raise NotImplementedError, "#{self.class}#execute must be implemented"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def safe_path(path)
|
|
53
|
+
expanded = if Pathname.new(path).absolute?
|
|
54
|
+
File.expand_path(path)
|
|
55
|
+
else
|
|
56
|
+
File.expand_path(path, project_root)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
unless expanded.start_with?(project_root)
|
|
60
|
+
raise PermissionDeniedError, "Path traversal denied: #{path} resolves outside project root"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
expanded
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def truncate(output, max: 50_000)
|
|
67
|
+
return output if output.nil? || output.length <= max
|
|
68
|
+
|
|
69
|
+
half = max / 2
|
|
70
|
+
"#{output[0, half]}\n\n... [truncated #{output.length - max} characters] ...\n\n#{output[-half, half]}"
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
def read_file_safely(path)
|
|
76
|
+
resolved = safe_path(path)
|
|
77
|
+
raise Error, "File not found: #{path}" unless File.exist?(resolved)
|
|
78
|
+
raise Error, "Not a file: #{path}" unless File.file?(resolved)
|
|
79
|
+
|
|
80
|
+
resolved
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
require "timeout"
|
|
5
|
+
require_relative "base"
|
|
6
|
+
require_relative "registry"
|
|
7
|
+
|
|
8
|
+
module RubynCode
|
|
9
|
+
module Tools
|
|
10
|
+
class Bash < Base
|
|
11
|
+
TOOL_NAME = "bash"
|
|
12
|
+
DESCRIPTION = "Runs a shell command in the project directory. Blocks dangerous patterns and scrubs sensitive environment variables."
|
|
13
|
+
PARAMETERS = {
|
|
14
|
+
command: { type: :string, required: true, description: "The shell command to execute" },
|
|
15
|
+
timeout: { type: :integer, required: false, default: 120, description: "Timeout in seconds (default: 120)" }
|
|
16
|
+
}.freeze
|
|
17
|
+
RISK_LEVEL = :execute
|
|
18
|
+
REQUIRES_CONFIRMATION = true
|
|
19
|
+
|
|
20
|
+
def execute(command:, timeout: 120)
|
|
21
|
+
validate_command!(command)
|
|
22
|
+
|
|
23
|
+
env = scrubbed_env
|
|
24
|
+
|
|
25
|
+
stdout, stderr, status = nil
|
|
26
|
+
begin
|
|
27
|
+
Timeout.timeout(timeout) do
|
|
28
|
+
stdout, stderr, status = Open3.capture3(env, command, chdir: project_root)
|
|
29
|
+
end
|
|
30
|
+
rescue Timeout::Error
|
|
31
|
+
raise Error, "Command timed out after #{timeout} seconds: #{command}"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
output = build_output(stdout, stderr, status)
|
|
35
|
+
output
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def validate_command!(command)
|
|
41
|
+
Config::Defaults::DANGEROUS_PATTERNS.each do |pattern|
|
|
42
|
+
if command.include?(pattern)
|
|
43
|
+
raise PermissionDeniedError, "Blocked dangerous command pattern: '#{pattern}'"
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def scrubbed_env
|
|
49
|
+
env = ENV.to_h.dup
|
|
50
|
+
|
|
51
|
+
env.each_key do |key|
|
|
52
|
+
if Config::Defaults::SCRUB_ENV_VARS.any? { |sensitive| key.upcase.include?(sensitive) }
|
|
53
|
+
env[key] = "[SCRUBBED]"
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
env
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def build_output(stdout, stderr, status)
|
|
61
|
+
parts = []
|
|
62
|
+
|
|
63
|
+
unless stdout.empty?
|
|
64
|
+
parts << stdout
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
unless stderr.empty?
|
|
68
|
+
parts << "STDERR:\n#{stderr}"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
unless status.success?
|
|
72
|
+
parts << "Exit code: #{status.exitstatus}"
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
parts.empty? ? "(no output)" : parts.join("\n")
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
Registry.register(Bash)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
require_relative "base"
|
|
5
|
+
require_relative "registry"
|
|
6
|
+
|
|
7
|
+
module RubynCode
|
|
8
|
+
module Tools
|
|
9
|
+
class BundleAdd < Base
|
|
10
|
+
TOOL_NAME = "bundle_add"
|
|
11
|
+
DESCRIPTION = "Adds a gem to the Gemfile and installs it via `bundle add`."
|
|
12
|
+
PARAMETERS = {
|
|
13
|
+
gem_name: { type: :string, required: true, description: "Name of the gem to add" },
|
|
14
|
+
version: { type: :string, required: false, description: "Version constraint (e.g. '~> 1.0')" },
|
|
15
|
+
group: { type: :string, required: false, description: "Gemfile group (e.g. 'development', 'test')" }
|
|
16
|
+
}.freeze
|
|
17
|
+
RISK_LEVEL = :execute
|
|
18
|
+
REQUIRES_CONFIRMATION = false
|
|
19
|
+
|
|
20
|
+
def execute(gem_name:, version: nil, group: nil)
|
|
21
|
+
gemfile_path = File.join(project_root, "Gemfile")
|
|
22
|
+
|
|
23
|
+
unless File.exist?(gemfile_path)
|
|
24
|
+
raise Error, "No Gemfile found in project root. Cannot run bundle add."
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
command = build_command(gem_name, version, group)
|
|
28
|
+
stdout, stderr, status = Open3.capture3(command, chdir: project_root)
|
|
29
|
+
|
|
30
|
+
build_output(stdout, stderr, status)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def build_command(gem_name, version, group)
|
|
36
|
+
cmd = "bundle add #{gem_name}"
|
|
37
|
+
cmd += " --version '#{version}'" if version
|
|
38
|
+
cmd += " --group #{group}" if group
|
|
39
|
+
cmd
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def build_output(stdout, stderr, status)
|
|
43
|
+
parts = []
|
|
44
|
+
parts << stdout unless stdout.empty?
|
|
45
|
+
parts << "STDERR:\n#{stderr}" unless stderr.empty?
|
|
46
|
+
parts << "Exit code: #{status.exitstatus}" unless status.success?
|
|
47
|
+
parts.empty? ? "(no output)" : parts.join("\n")
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
Registry.register(BundleAdd)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
require_relative "base"
|
|
5
|
+
require_relative "registry"
|
|
6
|
+
|
|
7
|
+
module RubynCode
|
|
8
|
+
module Tools
|
|
9
|
+
class BundleInstall < Base
|
|
10
|
+
TOOL_NAME = "bundle_install"
|
|
11
|
+
DESCRIPTION = "Runs `bundle install` to install gem dependencies."
|
|
12
|
+
PARAMETERS = {}.freeze
|
|
13
|
+
RISK_LEVEL = :execute
|
|
14
|
+
REQUIRES_CONFIRMATION = false
|
|
15
|
+
|
|
16
|
+
def execute(**_params)
|
|
17
|
+
gemfile_path = File.join(project_root, "Gemfile")
|
|
18
|
+
|
|
19
|
+
unless File.exist?(gemfile_path)
|
|
20
|
+
raise Error, "No Gemfile found in project root. Cannot run bundle install."
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
stdout, stderr, status = Open3.capture3("bundle install", chdir: project_root)
|
|
24
|
+
|
|
25
|
+
build_output(stdout, stderr, status)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def build_output(stdout, stderr, status)
|
|
31
|
+
parts = []
|
|
32
|
+
parts << stdout unless stdout.empty?
|
|
33
|
+
parts << "STDERR:\n#{stderr}" unless stderr.empty?
|
|
34
|
+
parts << "Exit code: #{status.exitstatus}" unless status.success?
|
|
35
|
+
parts.empty? ? "(no output)" : parts.join("\n")
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
Registry.register(BundleInstall)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base"
|
|
4
|
+
require_relative "registry"
|
|
5
|
+
|
|
6
|
+
module RubynCode
|
|
7
|
+
module Tools
|
|
8
|
+
class Compact < Base
|
|
9
|
+
TOOL_NAME = "compact"
|
|
10
|
+
DESCRIPTION = "Triggers manual context compaction to reduce conversation size while preserving key information."
|
|
11
|
+
PARAMETERS = {
|
|
12
|
+
focus: { type: :string, required: false, description: "What to focus the summary on (e.g. 'the auth refactor', 'test failures')" }
|
|
13
|
+
}.freeze
|
|
14
|
+
RISK_LEVEL = :read
|
|
15
|
+
REQUIRES_CONFIRMATION = false
|
|
16
|
+
|
|
17
|
+
def initialize(project_root:, context_manager: nil)
|
|
18
|
+
super(project_root: project_root)
|
|
19
|
+
@context_manager = context_manager
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def execute(focus: nil)
|
|
23
|
+
manager = @context_manager
|
|
24
|
+
|
|
25
|
+
unless manager
|
|
26
|
+
return "Context compaction is not available in this session. " \
|
|
27
|
+
"No context manager was provided."
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
if manager.respond_to?(:compact)
|
|
31
|
+
result = manager.compact(focus: focus)
|
|
32
|
+
format_result(result, focus)
|
|
33
|
+
else
|
|
34
|
+
"Context manager does not support compaction."
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def format_result(result, focus)
|
|
41
|
+
parts = ["Context compacted successfully."]
|
|
42
|
+
|
|
43
|
+
if result.is_a?(Hash)
|
|
44
|
+
parts << "Messages before: #{result[:before]}" if result[:before]
|
|
45
|
+
parts << "Messages after: #{result[:after]}" if result[:after]
|
|
46
|
+
parts << "Tokens saved: ~#{result[:tokens_saved]}" if result[:tokens_saved]
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
parts << "Focus: #{focus}" if focus
|
|
50
|
+
|
|
51
|
+
parts.join("\n")
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
Registry.register(Compact)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
require_relative "base"
|
|
5
|
+
require_relative "registry"
|
|
6
|
+
|
|
7
|
+
module RubynCode
|
|
8
|
+
module Tools
|
|
9
|
+
class DbMigrate < Base
|
|
10
|
+
TOOL_NAME = "db_migrate"
|
|
11
|
+
DESCRIPTION = "Runs Rails database migrations (up) or rollback (down)."
|
|
12
|
+
PARAMETERS = {
|
|
13
|
+
direction: { type: :string, required: false, default: "up", enum: %w[up down], description: "Migration direction: 'up' to migrate, 'down' to rollback (default: 'up')" },
|
|
14
|
+
steps: { type: :integer, required: false, description: "Number of steps to rollback (only used with direction 'down')" }
|
|
15
|
+
}.freeze
|
|
16
|
+
RISK_LEVEL = :execute
|
|
17
|
+
REQUIRES_CONFIRMATION = false
|
|
18
|
+
|
|
19
|
+
def execute(direction: "up", steps: nil)
|
|
20
|
+
command = build_command(direction, steps)
|
|
21
|
+
stdout, stderr, status = Open3.capture3(command, chdir: project_root)
|
|
22
|
+
|
|
23
|
+
build_output(stdout, stderr, status)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def build_command(direction, steps)
|
|
29
|
+
case direction
|
|
30
|
+
when "up"
|
|
31
|
+
"bundle exec rails db:migrate"
|
|
32
|
+
when "down"
|
|
33
|
+
cmd = "bundle exec rails db:rollback"
|
|
34
|
+
cmd += " STEP=#{steps.to_i}" if steps && steps.to_i > 0
|
|
35
|
+
cmd
|
|
36
|
+
else
|
|
37
|
+
raise Error, "Invalid direction: #{direction}. Must be 'up' or 'down'."
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def build_output(stdout, stderr, status)
|
|
42
|
+
parts = []
|
|
43
|
+
parts << stdout unless stdout.empty?
|
|
44
|
+
parts << "STDERR:\n#{stderr}" unless stderr.empty?
|
|
45
|
+
parts << "Exit code: #{status.exitstatus}" unless status.success?
|
|
46
|
+
parts.empty? ? "(no output)" : parts.join("\n")
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
Registry.register(DbMigrate)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base"
|
|
4
|
+
require_relative "registry"
|
|
5
|
+
|
|
6
|
+
module RubynCode
|
|
7
|
+
module Tools
|
|
8
|
+
class EditFile < Base
|
|
9
|
+
TOOL_NAME = "edit_file"
|
|
10
|
+
DESCRIPTION = "Performs exact string replacement in a file. Fails if old_text is not found or is ambiguous."
|
|
11
|
+
PARAMETERS = {
|
|
12
|
+
path: { type: :string, required: true, description: "Path to the file to edit" },
|
|
13
|
+
old_text: { type: :string, required: true, description: "The exact text to find and replace" },
|
|
14
|
+
new_text: { type: :string, required: true, description: "The replacement text" },
|
|
15
|
+
replace_all: { type: :boolean, required: false, default: false, description: "Replace all occurrences (default: false)" }
|
|
16
|
+
}.freeze
|
|
17
|
+
RISK_LEVEL = :write
|
|
18
|
+
REQUIRES_CONFIRMATION = false
|
|
19
|
+
|
|
20
|
+
def execute(path:, old_text:, new_text:, replace_all: false)
|
|
21
|
+
resolved = read_file_safely(path)
|
|
22
|
+
content = File.read(resolved)
|
|
23
|
+
|
|
24
|
+
occurrences = content.scan(old_text).length
|
|
25
|
+
|
|
26
|
+
if occurrences.zero?
|
|
27
|
+
raise Error, "old_text not found in #{path}. No changes made."
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
if !replace_all && occurrences > 1
|
|
31
|
+
raise Error, "old_text found #{occurrences} times in #{path}. Use replace_all: true to replace all, or provide a more specific old_text."
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
new_content = if replace_all
|
|
35
|
+
content.gsub(old_text, new_text)
|
|
36
|
+
else
|
|
37
|
+
content.sub(old_text, new_text)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
File.write(resolved, new_content)
|
|
41
|
+
|
|
42
|
+
replaced_count = replace_all ? occurrences : 1
|
|
43
|
+
"Successfully replaced #{replaced_count} occurrence#{'s' if replaced_count > 1} in #{path}"
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
Registry.register(EditFile)
|
|
48
|
+
end
|
|
49
|
+
end
|