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,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubynCode
|
|
4
|
+
module Tools
|
|
5
|
+
class Executor
|
|
6
|
+
attr_reader :project_root
|
|
7
|
+
attr_accessor :llm_client, :background_worker, :on_agent_status, :db
|
|
8
|
+
|
|
9
|
+
def initialize(project_root:)
|
|
10
|
+
@project_root = File.expand_path(project_root)
|
|
11
|
+
@injections = {}
|
|
12
|
+
Registry.load_all!
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def execute(tool_name, params)
|
|
16
|
+
tool_class = Registry.get(tool_name)
|
|
17
|
+
tool = tool_class.new(project_root: project_root)
|
|
18
|
+
|
|
19
|
+
# Inject dependencies for tools that need them
|
|
20
|
+
inject_dependencies(tool, tool_name)
|
|
21
|
+
|
|
22
|
+
symbolized = params.transform_keys(&:to_sym)
|
|
23
|
+
result = tool.execute(**symbolized)
|
|
24
|
+
tool.truncate(result.to_s)
|
|
25
|
+
rescue ToolNotFoundError => e
|
|
26
|
+
error_result("Tool error: #{e.message}")
|
|
27
|
+
rescue PermissionDeniedError => e
|
|
28
|
+
error_result("Permission denied: #{e.message}")
|
|
29
|
+
rescue NotImplementedError => e
|
|
30
|
+
error_result("Not implemented: #{e.message}")
|
|
31
|
+
rescue Error => e
|
|
32
|
+
error_result("Error: #{e.message}")
|
|
33
|
+
rescue StandardError => e
|
|
34
|
+
error_result("Unexpected error in #{tool_name}: #{e.class}: #{e.message}")
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def tool_definitions
|
|
38
|
+
Registry.tool_definitions
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def inject_dependencies(tool, tool_name)
|
|
44
|
+
case tool_name
|
|
45
|
+
when "spawn_agent"
|
|
46
|
+
tool.llm_client = @llm_client if tool.respond_to?(:llm_client=)
|
|
47
|
+
tool.on_status = @on_agent_status if tool.respond_to?(:on_status=)
|
|
48
|
+
when "spawn_teammate"
|
|
49
|
+
tool.llm_client = @llm_client if tool.respond_to?(:llm_client=)
|
|
50
|
+
tool.on_status = @on_agent_status if tool.respond_to?(:on_status=)
|
|
51
|
+
tool.db = @db if tool.respond_to?(:db=)
|
|
52
|
+
when "background_run"
|
|
53
|
+
tool.background_worker = @background_worker if tool.respond_to?(:background_worker=)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def error_result(message)
|
|
58
|
+
message
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,97 @@
|
|
|
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 GitCommit < Base
|
|
10
|
+
TOOL_NAME = "git_commit"
|
|
11
|
+
DESCRIPTION = "Stage files and create a git commit. Specify files to stage or use 'all' to stage everything."
|
|
12
|
+
PARAMETERS = {
|
|
13
|
+
message: { type: :string, required: true, description: "The commit message" },
|
|
14
|
+
files: { type: :string, required: false, default: "all", description: "Space-separated file paths to stage, or 'all' to stage everything (git add -A)" }
|
|
15
|
+
}.freeze
|
|
16
|
+
RISK_LEVEL = :write
|
|
17
|
+
REQUIRES_CONFIRMATION = true
|
|
18
|
+
|
|
19
|
+
def execute(message:, files: "all")
|
|
20
|
+
validate_git_repo!
|
|
21
|
+
validate_message!(message)
|
|
22
|
+
|
|
23
|
+
stage_files(files)
|
|
24
|
+
commit_output = create_commit(message)
|
|
25
|
+
|
|
26
|
+
commit_output
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def validate_git_repo!
|
|
32
|
+
_, _, status = Open3.capture3("git", "rev-parse", "--is-inside-work-tree", chdir: project_root)
|
|
33
|
+
unless status.success?
|
|
34
|
+
raise Error, "Not a git repository: #{project_root}"
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def validate_message!(message)
|
|
39
|
+
if message.nil? || message.strip.empty?
|
|
40
|
+
raise Error, "Commit message cannot be empty"
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def stage_files(files)
|
|
45
|
+
if files.strip.downcase == "all"
|
|
46
|
+
stdout, stderr, status = Open3.capture3("git", "add", "-A", chdir: project_root)
|
|
47
|
+
else
|
|
48
|
+
file_list = files.split(/\s+/).reject(&:empty?)
|
|
49
|
+
if file_list.empty?
|
|
50
|
+
raise Error, "No files specified to stage"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
stdout, stderr, status = Open3.capture3("git", "add", "--", *file_list, chdir: project_root)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
unless status.success?
|
|
57
|
+
raise Error, "Failed to stage files: #{stderr.strip}"
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def create_commit(message)
|
|
62
|
+
stdout, stderr, status = Open3.capture3("git", "commit", "-m", message, chdir: project_root)
|
|
63
|
+
|
|
64
|
+
unless status.success?
|
|
65
|
+
if stderr.include?("nothing to commit")
|
|
66
|
+
return "Nothing to commit — working tree is clean."
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
raise Error, "Commit failed: #{stderr.strip}"
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Extract the commit hash from the output
|
|
73
|
+
commit_hash = extract_commit_hash
|
|
74
|
+
branch = current_branch
|
|
75
|
+
|
|
76
|
+
lines = ["Committed on branch: #{branch}"]
|
|
77
|
+
lines << "Commit: #{commit_hash}" if commit_hash
|
|
78
|
+
lines << ""
|
|
79
|
+
lines << stdout.strip
|
|
80
|
+
|
|
81
|
+
lines.join("\n")
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def extract_commit_hash
|
|
85
|
+
stdout, _, status = Open3.capture3("git", "rev-parse", "--short", "HEAD", chdir: project_root)
|
|
86
|
+
status.success? ? stdout.strip : nil
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def current_branch
|
|
90
|
+
stdout, _, status = Open3.capture3("git", "branch", "--show-current", chdir: project_root)
|
|
91
|
+
status.success? && !stdout.strip.empty? ? stdout.strip : "HEAD (detached)"
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
Registry.register(GitCommit)
|
|
96
|
+
end
|
|
97
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
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 GitDiff < Base
|
|
10
|
+
TOOL_NAME = "git_diff"
|
|
11
|
+
DESCRIPTION = "Show git diff for staged, unstaged, or between branches/commits."
|
|
12
|
+
PARAMETERS = {
|
|
13
|
+
target: { type: :string, required: false, default: "unstaged", description: 'What to diff: "staged", "unstaged", or a branch/commit ref (default: "unstaged")' }
|
|
14
|
+
}.freeze
|
|
15
|
+
RISK_LEVEL = :read
|
|
16
|
+
REQUIRES_CONFIRMATION = false
|
|
17
|
+
|
|
18
|
+
MAX_DIFF_LENGTH = 80_000
|
|
19
|
+
|
|
20
|
+
def execute(target: "unstaged")
|
|
21
|
+
validate_git_repo!
|
|
22
|
+
|
|
23
|
+
cmd = build_diff_command(target.to_s.strip)
|
|
24
|
+
stdout, stderr, status = Open3.capture3(*cmd, chdir: project_root)
|
|
25
|
+
|
|
26
|
+
unless status.success?
|
|
27
|
+
raise Error, "git diff failed: #{stderr.strip}"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
if stdout.strip.empty?
|
|
31
|
+
"No differences found for target: #{target}"
|
|
32
|
+
else
|
|
33
|
+
header = "git diff (#{target}):\n\n"
|
|
34
|
+
truncate("#{header}#{stdout}", max: MAX_DIFF_LENGTH)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def validate_git_repo!
|
|
41
|
+
_, _, status = Open3.capture3("git", "rev-parse", "--is-inside-work-tree", chdir: project_root)
|
|
42
|
+
unless status.success?
|
|
43
|
+
raise Error, "Not a git repository: #{project_root}"
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def build_diff_command(target)
|
|
48
|
+
case target.downcase
|
|
49
|
+
when "staged", "cached"
|
|
50
|
+
%w[git diff --cached]
|
|
51
|
+
when "unstaged", ""
|
|
52
|
+
%w[git diff]
|
|
53
|
+
else
|
|
54
|
+
["git", "diff", target]
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
Registry.register(GitDiff)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
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 GitLog < Base
|
|
10
|
+
TOOL_NAME = "git_log"
|
|
11
|
+
DESCRIPTION = "Show recent git commit history."
|
|
12
|
+
PARAMETERS = {
|
|
13
|
+
count: { type: :integer, required: false, default: 20, description: "Number of commits to show (default: 20)" },
|
|
14
|
+
branch: { type: :string, required: false, description: "Branch name to show log for (default: current branch)" }
|
|
15
|
+
}.freeze
|
|
16
|
+
RISK_LEVEL = :read
|
|
17
|
+
REQUIRES_CONFIRMATION = false
|
|
18
|
+
|
|
19
|
+
def execute(count: 20, branch: nil)
|
|
20
|
+
validate_git_repo!
|
|
21
|
+
|
|
22
|
+
count = [[count.to_i, 1].max, 200].min
|
|
23
|
+
|
|
24
|
+
cmd = ["git", "log", "--oneline", "-#{count}"]
|
|
25
|
+
cmd << branch unless branch.nil? || branch.strip.empty?
|
|
26
|
+
|
|
27
|
+
stdout, stderr, status = Open3.capture3(*cmd, chdir: project_root)
|
|
28
|
+
|
|
29
|
+
unless status.success?
|
|
30
|
+
raise Error, "git log failed: #{stderr.strip}"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
if stdout.strip.empty?
|
|
34
|
+
"No commits found."
|
|
35
|
+
else
|
|
36
|
+
current = current_branch
|
|
37
|
+
header = "Commit history#{branch ? " (#{branch})" : " (#{current})"}:\n\n"
|
|
38
|
+
truncate("#{header}#{stdout}", max: 50_000)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def validate_git_repo!
|
|
45
|
+
_, _, status = Open3.capture3("git", "rev-parse", "--is-inside-work-tree", chdir: project_root)
|
|
46
|
+
unless status.success?
|
|
47
|
+
raise Error, "Not a git repository: #{project_root}"
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def current_branch
|
|
52
|
+
stdout, _, status = Open3.capture3("git", "branch", "--show-current", chdir: project_root)
|
|
53
|
+
status.success? && !stdout.strip.empty? ? stdout.strip : "HEAD (detached)"
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
Registry.register(GitLog)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
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 GitStatus < Base
|
|
10
|
+
TOOL_NAME = "git_status"
|
|
11
|
+
DESCRIPTION = "Show the current git status — modified, staged, and untracked files."
|
|
12
|
+
PARAMETERS = {}.freeze
|
|
13
|
+
RISK_LEVEL = :read
|
|
14
|
+
REQUIRES_CONFIRMATION = false
|
|
15
|
+
|
|
16
|
+
def execute(**_params)
|
|
17
|
+
validate_git_repo!
|
|
18
|
+
|
|
19
|
+
branch = current_branch
|
|
20
|
+
status_output = git_status
|
|
21
|
+
|
|
22
|
+
lines = ["Branch: #{branch}\n"]
|
|
23
|
+
|
|
24
|
+
if status_output.strip.empty?
|
|
25
|
+
lines << "Working tree is clean — nothing to commit."
|
|
26
|
+
else
|
|
27
|
+
lines << status_output
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
lines.join("\n")
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def validate_git_repo!
|
|
36
|
+
_, _, status = Open3.capture3("git", "rev-parse", "--is-inside-work-tree", chdir: project_root)
|
|
37
|
+
unless status.success?
|
|
38
|
+
raise Error, "Not a git repository: #{project_root}"
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def current_branch
|
|
43
|
+
stdout, _, status = Open3.capture3("git", "branch", "--show-current", chdir: project_root)
|
|
44
|
+
status.success? && !stdout.strip.empty? ? stdout.strip : "HEAD (detached)"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def git_status
|
|
48
|
+
stdout, stderr, status = Open3.capture3("git", "status", "--short", chdir: project_root)
|
|
49
|
+
unless status.success?
|
|
50
|
+
raise Error, "git status failed: #{stderr.strip}"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
stdout
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
Registry.register(GitStatus)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base"
|
|
4
|
+
require_relative "registry"
|
|
5
|
+
|
|
6
|
+
module RubynCode
|
|
7
|
+
module Tools
|
|
8
|
+
class Glob < Base
|
|
9
|
+
TOOL_NAME = "glob"
|
|
10
|
+
DESCRIPTION = "File pattern matching. Returns sorted list of file paths matching the glob pattern."
|
|
11
|
+
PARAMETERS = {
|
|
12
|
+
pattern: { type: :string, required: true, description: "Glob pattern (e.g. '**/*.rb', 'app/**/*.erb')" },
|
|
13
|
+
path: { type: :string, required: false, description: "Directory to search in (defaults to project root)" }
|
|
14
|
+
}.freeze
|
|
15
|
+
RISK_LEVEL = :read
|
|
16
|
+
REQUIRES_CONFIRMATION = false
|
|
17
|
+
|
|
18
|
+
def execute(pattern:, path: nil)
|
|
19
|
+
search_dir = path ? safe_path(path) : project_root
|
|
20
|
+
|
|
21
|
+
unless File.directory?(search_dir)
|
|
22
|
+
raise Error, "Directory not found: #{path || project_root}"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
full_pattern = File.join(search_dir, pattern)
|
|
26
|
+
matches = Dir.glob(full_pattern, File::FNM_DOTMATCH).sort
|
|
27
|
+
|
|
28
|
+
matches
|
|
29
|
+
.select { |f| File.file?(f) }
|
|
30
|
+
.reject { |f| File.basename(f).start_with?(".") && File.basename(f) == "." || File.basename(f) == ".." }
|
|
31
|
+
.map { |f| relative_to_root(f) }
|
|
32
|
+
.join("\n")
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def relative_to_root(absolute_path)
|
|
38
|
+
absolute_path.delete_prefix("#{project_root}/")
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
Registry.register(Glob)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base"
|
|
4
|
+
require_relative "registry"
|
|
5
|
+
|
|
6
|
+
module RubynCode
|
|
7
|
+
module Tools
|
|
8
|
+
class Grep < Base
|
|
9
|
+
TOOL_NAME = "grep"
|
|
10
|
+
DESCRIPTION = "Searches file contents using regular expressions. Returns matching lines with file paths and line numbers."
|
|
11
|
+
PARAMETERS = {
|
|
12
|
+
pattern: { type: :string, required: true, description: "Regular expression pattern to search for" },
|
|
13
|
+
path: { type: :string, required: false, description: "File or directory to search in (defaults to project root)" },
|
|
14
|
+
glob_filter: { type: :string, required: false, description: "Glob pattern to filter files (e.g. '*.rb')" },
|
|
15
|
+
max_results: { type: :integer, required: false, default: 50, description: "Maximum number of matching lines to return" }
|
|
16
|
+
}.freeze
|
|
17
|
+
RISK_LEVEL = :read
|
|
18
|
+
REQUIRES_CONFIRMATION = false
|
|
19
|
+
|
|
20
|
+
def execute(pattern:, path: nil, glob_filter: nil, max_results: 50)
|
|
21
|
+
search_path = path ? safe_path(path) : project_root
|
|
22
|
+
regex = Regexp.new(pattern)
|
|
23
|
+
|
|
24
|
+
files = collect_files(search_path, glob_filter)
|
|
25
|
+
results = []
|
|
26
|
+
|
|
27
|
+
files.each do |file|
|
|
28
|
+
break if results.length >= max_results
|
|
29
|
+
|
|
30
|
+
search_file(file, regex, results, max_results)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
return "No matches found for pattern: #{pattern}" if results.empty?
|
|
34
|
+
|
|
35
|
+
results.join("\n")
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def collect_files(search_path, glob_filter)
|
|
41
|
+
if File.file?(search_path)
|
|
42
|
+
[search_path]
|
|
43
|
+
elsif File.directory?(search_path)
|
|
44
|
+
glob_pattern = glob_filter || "**/*"
|
|
45
|
+
Dir.glob(File.join(search_path, glob_pattern))
|
|
46
|
+
.select { |f| File.file?(f) }
|
|
47
|
+
.reject { |f| binary_file?(f) }
|
|
48
|
+
.sort
|
|
49
|
+
else
|
|
50
|
+
raise Error, "Path not found: #{search_path}"
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def search_file(file, regex, results, max_results)
|
|
55
|
+
File.foreach(file).with_index(1) do |line, line_num|
|
|
56
|
+
break if results.length >= max_results
|
|
57
|
+
|
|
58
|
+
if line.match?(regex)
|
|
59
|
+
relative = file.delete_prefix("#{project_root}/")
|
|
60
|
+
results << "#{relative}:#{line_num}: #{line.chomp}"
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
rescue ArgumentError, Encoding::InvalidByteSequenceError
|
|
64
|
+
# Skip files with encoding issues
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def binary_file?(path)
|
|
68
|
+
return true if path.match?(/\.(png|jpg|jpeg|gif|ico|woff|woff2|ttf|eot|pdf|zip|gz|tar|so|dylib|o|a)\z/i)
|
|
69
|
+
|
|
70
|
+
sample = File.read(path, 512)
|
|
71
|
+
return false if sample.nil?
|
|
72
|
+
|
|
73
|
+
sample.bytes.any? { |b| b.zero? }
|
|
74
|
+
rescue StandardError
|
|
75
|
+
true
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
Registry.register(Grep)
|
|
80
|
+
end
|
|
81
|
+
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 LoadSkill < Base
|
|
9
|
+
TOOL_NAME = "load_skill"
|
|
10
|
+
DESCRIPTION = "Loads a skill document into the conversation context. Use /skill-name or provide the skill name."
|
|
11
|
+
PARAMETERS = {
|
|
12
|
+
name: { type: :string, required: true, description: "Name of the skill to load" }
|
|
13
|
+
}.freeze
|
|
14
|
+
RISK_LEVEL = :read
|
|
15
|
+
REQUIRES_CONFIRMATION = false
|
|
16
|
+
|
|
17
|
+
def initialize(project_root:, skill_loader: nil)
|
|
18
|
+
super(project_root: project_root)
|
|
19
|
+
@skill_loader = skill_loader
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def execute(name:)
|
|
23
|
+
loader = @skill_loader || default_loader
|
|
24
|
+
loader.load(name)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def default_loader
|
|
30
|
+
skills_dirs = [
|
|
31
|
+
File.join(project_root, ".rubyn", "skills"),
|
|
32
|
+
File.join(Dir.home, ".rubyn", "skills")
|
|
33
|
+
]
|
|
34
|
+
catalog = Skills::Catalog.new(skills_dirs)
|
|
35
|
+
Skills::Loader.new(catalog)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
Registry.register(LoadSkill)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base"
|
|
4
|
+
require_relative "registry"
|
|
5
|
+
|
|
6
|
+
module RubynCode
|
|
7
|
+
module Tools
|
|
8
|
+
class MemorySearch < Base
|
|
9
|
+
TOOL_NAME = "memory_search"
|
|
10
|
+
DESCRIPTION = "Searches project memories using full-text search. " \
|
|
11
|
+
"Returns relevant memories including code patterns, user preferences, " \
|
|
12
|
+
"project conventions, error resolutions, and past decisions."
|
|
13
|
+
PARAMETERS = {
|
|
14
|
+
query: { type: :string, required: true, description: "Search query for finding relevant memories" },
|
|
15
|
+
tier: { type: :string, required: false, description: "Filter by memory tier: short, medium, or long" },
|
|
16
|
+
category: { type: :string, required: false, description: "Filter by category: code_pattern, user_preference, project_convention, error_resolution, or decision" },
|
|
17
|
+
limit: { type: :integer, required: false, description: "Maximum number of results to return (default 10)" }
|
|
18
|
+
}.freeze
|
|
19
|
+
RISK_LEVEL = :read
|
|
20
|
+
REQUIRES_CONFIRMATION = false
|
|
21
|
+
|
|
22
|
+
# @param project_root [String]
|
|
23
|
+
# @param memory_search [Memory::Search] injected search instance
|
|
24
|
+
def initialize(project_root:, memory_search: nil)
|
|
25
|
+
super(project_root: project_root)
|
|
26
|
+
@memory_search = memory_search
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# @param query [String]
|
|
30
|
+
# @param tier [String, nil]
|
|
31
|
+
# @param category [String, nil]
|
|
32
|
+
# @param limit [Integer, nil]
|
|
33
|
+
# @return [String] formatted search results
|
|
34
|
+
def execute(query:, tier: nil, category: nil, limit: 10)
|
|
35
|
+
search = @memory_search || resolve_memory_search
|
|
36
|
+
results = search.search(query, tier: tier, category: category, limit: limit.to_i)
|
|
37
|
+
|
|
38
|
+
return "No memories found for query: #{query}" if results.empty?
|
|
39
|
+
|
|
40
|
+
format_results(results)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
# Formats an array of MemoryRecord into a human-readable string.
|
|
46
|
+
#
|
|
47
|
+
# @param records [Array<Memory::MemoryRecord>]
|
|
48
|
+
# @return [String]
|
|
49
|
+
def format_results(records)
|
|
50
|
+
lines = ["Found #{records.size} memor#{records.size == 1 ? 'y' : 'ies'}:\n"]
|
|
51
|
+
|
|
52
|
+
records.each_with_index do |record, idx|
|
|
53
|
+
lines << "--- Memory #{idx + 1} ---"
|
|
54
|
+
lines << "ID: #{record.id}"
|
|
55
|
+
lines << "Tier: #{record.tier} | Category: #{record.category || 'none'}"
|
|
56
|
+
lines << "Relevance: #{format('%.2f', record.relevance_score)} | Accessed: #{record.access_count} times"
|
|
57
|
+
lines << "Created: #{record.created_at}"
|
|
58
|
+
lines << ""
|
|
59
|
+
lines << record.content
|
|
60
|
+
lines << ""
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
lines.join("\n")
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Lazily resolves a Memory::Search instance from the project root.
|
|
67
|
+
#
|
|
68
|
+
# @return [Memory::Search]
|
|
69
|
+
def resolve_memory_search
|
|
70
|
+
db = DB::Connection.instance
|
|
71
|
+
Memory::Search.new(db, project_path: project_root)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
Registry.register(MemorySearch)
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base"
|
|
4
|
+
require_relative "registry"
|
|
5
|
+
|
|
6
|
+
module RubynCode
|
|
7
|
+
module Tools
|
|
8
|
+
class MemoryWrite < Base
|
|
9
|
+
TOOL_NAME = "memory_write"
|
|
10
|
+
DESCRIPTION = "Writes a new memory to the project memory store. " \
|
|
11
|
+
"Use this to persist code patterns, user preferences, project conventions, " \
|
|
12
|
+
"error resolutions, or architectural decisions for future reference."
|
|
13
|
+
PARAMETERS = {
|
|
14
|
+
content: { type: :string, required: true, description: "The memory content to store" },
|
|
15
|
+
tier: { type: :string, required: false, description: "Memory retention tier: short, medium (default), or long" },
|
|
16
|
+
category: { type: :string, required: false, description: "Category: code_pattern, user_preference, project_convention, error_resolution, or decision" }
|
|
17
|
+
}.freeze
|
|
18
|
+
RISK_LEVEL = :read # Memory is internal — no user approval needed
|
|
19
|
+
|
|
20
|
+
# @param project_root [String]
|
|
21
|
+
# @param memory_store [Memory::Store] injected store instance
|
|
22
|
+
def initialize(project_root:, memory_store: nil)
|
|
23
|
+
super(project_root: project_root)
|
|
24
|
+
@memory_store = memory_store
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# @param content [String]
|
|
28
|
+
# @param tier [String] defaults to "medium"
|
|
29
|
+
# @param category [String, nil]
|
|
30
|
+
# @return [String] confirmation message
|
|
31
|
+
def execute(content:, tier: "medium", category: nil)
|
|
32
|
+
store = @memory_store || resolve_memory_store
|
|
33
|
+
record = store.write(content: content, tier: tier, category: category)
|
|
34
|
+
|
|
35
|
+
"Memory saved (ID: #{record.id}, tier: #{record.tier}" \
|
|
36
|
+
"#{record.category ? ", category: #{record.category}" : ''})."
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
# Lazily resolves a Memory::Store instance from the project root.
|
|
42
|
+
#
|
|
43
|
+
# @return [Memory::Store]
|
|
44
|
+
def resolve_memory_store
|
|
45
|
+
db = DB::Connection.instance
|
|
46
|
+
Memory::Store.new(db, project_path: project_root)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
Registry.register(MemoryWrite)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
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 RailsGenerate < Base
|
|
10
|
+
TOOL_NAME = "rails_generate"
|
|
11
|
+
DESCRIPTION = "Runs a Rails generator command. Validates that the project is a Rails application."
|
|
12
|
+
PARAMETERS = {
|
|
13
|
+
generator: { type: :string, required: true, description: "Generator name (e.g. 'model', 'controller', 'migration')" },
|
|
14
|
+
args: { type: :string, required: true, description: "Arguments for the generator (e.g. 'User name:string email:string')" }
|
|
15
|
+
}.freeze
|
|
16
|
+
RISK_LEVEL = :execute
|
|
17
|
+
REQUIRES_CONFIRMATION = false
|
|
18
|
+
|
|
19
|
+
def execute(generator:, args:)
|
|
20
|
+
validate_rails_project!
|
|
21
|
+
|
|
22
|
+
command = "bundle exec rails generate #{generator} #{args}"
|
|
23
|
+
stdout, stderr, status = Open3.capture3(command, chdir: project_root)
|
|
24
|
+
|
|
25
|
+
build_output(stdout, stderr, status)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def validate_rails_project!
|
|
31
|
+
gemfile_path = File.join(project_root, "Gemfile")
|
|
32
|
+
|
|
33
|
+
unless File.exist?(gemfile_path)
|
|
34
|
+
raise Error, "No Gemfile found. This does not appear to be a Ruby project."
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
gemfile_content = File.read(gemfile_path)
|
|
38
|
+
unless gemfile_content.match?(/['"]rails['"]/)
|
|
39
|
+
raise Error, "Gemfile does not include Rails. This does not appear to be a Rails project."
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def build_output(stdout, stderr, status)
|
|
44
|
+
parts = []
|
|
45
|
+
parts << stdout unless stdout.empty?
|
|
46
|
+
parts << "STDERR:\n#{stderr}" unless stderr.empty?
|
|
47
|
+
parts << "Exit code: #{status.exitstatus}" unless status.success?
|
|
48
|
+
parts.empty? ? "(no output)" : parts.join("\n")
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
Registry.register(RailsGenerate)
|
|
53
|
+
end
|
|
54
|
+
end
|