rubyn-code 0.2.2 → 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 +4 -4
- data/README.md +151 -5
- data/db/migrations/013_add_failed_status_to_tasks.rb +51 -0
- data/lib/rubyn_code/agent/background_job_handler.rb +71 -0
- data/lib/rubyn_code/agent/conversation.rb +84 -56
- data/lib/rubyn_code/agent/dynamic_tool_schema.rb +152 -0
- data/lib/rubyn_code/agent/feedback_handler.rb +49 -0
- data/lib/rubyn_code/agent/llm_caller.rb +157 -0
- data/lib/rubyn_code/agent/loop.rb +182 -683
- data/lib/rubyn_code/agent/loop_detector.rb +50 -11
- data/lib/rubyn_code/agent/prompts.rb +109 -0
- data/lib/rubyn_code/agent/response_modes.rb +111 -0
- data/lib/rubyn_code/agent/response_parser.rb +111 -0
- data/lib/rubyn_code/agent/system_prompt_builder.rb +211 -0
- data/lib/rubyn_code/agent/tool_processor.rb +178 -0
- data/lib/rubyn_code/agent/usage_tracker.rb +59 -0
- data/lib/rubyn_code/auth/key_encryption.rb +118 -0
- data/lib/rubyn_code/auth/oauth.rb +80 -64
- data/lib/rubyn_code/auth/server.rb +21 -24
- data/lib/rubyn_code/auth/token_store.rb +80 -52
- data/lib/rubyn_code/autonomous/daemon.rb +146 -32
- data/lib/rubyn_code/autonomous/idle_poller.rb +4 -24
- data/lib/rubyn_code/autonomous/task_claimer.rb +46 -44
- data/lib/rubyn_code/background/worker.rb +64 -76
- data/lib/rubyn_code/cli/app.rb +159 -114
- data/lib/rubyn_code/cli/commands/doctor.rb +73 -0
- data/lib/rubyn_code/cli/commands/mcp.rb +77 -0
- data/lib/rubyn_code/cli/commands/model.rb +105 -18
- data/lib/rubyn_code/cli/commands/new_session.rb +45 -0
- data/lib/rubyn_code/cli/commands/provider.rb +123 -0
- data/lib/rubyn_code/cli/commands/skill.rb +52 -3
- data/lib/rubyn_code/cli/daemon_runner.rb +64 -11
- data/lib/rubyn_code/cli/first_run.rb +159 -0
- data/lib/rubyn_code/cli/renderer.rb +109 -60
- data/lib/rubyn_code/cli/repl.rb +48 -374
- data/lib/rubyn_code/cli/repl_commands.rb +177 -0
- data/lib/rubyn_code/cli/repl_lifecycle.rb +76 -0
- data/lib/rubyn_code/cli/repl_setup.rb +181 -0
- data/lib/rubyn_code/cli/setup.rb +6 -2
- data/lib/rubyn_code/cli/stream_formatter.rb +56 -49
- data/lib/rubyn_code/cli/version_check.rb +28 -11
- data/lib/rubyn_code/config/defaults.rb +11 -0
- data/lib/rubyn_code/config/project_profile.rb +185 -0
- data/lib/rubyn_code/config/schema.json +49 -0
- data/lib/rubyn_code/config/settings.rb +103 -1
- data/lib/rubyn_code/config/validator.rb +63 -0
- data/lib/rubyn_code/context/auto_compact.rb +1 -1
- data/lib/rubyn_code/context/context_budget.rb +182 -0
- data/lib/rubyn_code/context/context_collapse.rb +34 -4
- data/lib/rubyn_code/context/decision_compactor.rb +99 -0
- data/lib/rubyn_code/context/manager.rb +44 -8
- data/lib/rubyn_code/context/manual_compact.rb +1 -1
- data/lib/rubyn_code/context/micro_compact.rb +29 -19
- data/lib/rubyn_code/context/schema_filter.rb +64 -0
- data/lib/rubyn_code/db/connection.rb +31 -26
- data/lib/rubyn_code/db/migrator.rb +44 -28
- data/lib/rubyn_code/hooks/built_in.rb +14 -10
- data/lib/rubyn_code/hooks/registry.rb +4 -0
- data/lib/rubyn_code/ide/adapters/tool_output.rb +330 -0
- data/lib/rubyn_code/ide/client.rb +110 -0
- data/lib/rubyn_code/ide/handlers/accept_edit_handler.rb +35 -0
- data/lib/rubyn_code/ide/handlers/approve_tool_use_handler.rb +34 -0
- data/lib/rubyn_code/ide/handlers/cancel_handler.rb +41 -0
- data/lib/rubyn_code/ide/handlers/config_get_handler.rb +63 -0
- data/lib/rubyn_code/ide/handlers/config_set_handler.rb +86 -0
- data/lib/rubyn_code/ide/handlers/initialize_handler.rb +79 -0
- data/lib/rubyn_code/ide/handlers/models_list_handler.rb +39 -0
- data/lib/rubyn_code/ide/handlers/prompt_handler.rb +215 -0
- data/lib/rubyn_code/ide/handlers/review_handler.rb +110 -0
- data/lib/rubyn_code/ide/handlers/session_fork_handler.rb +49 -0
- data/lib/rubyn_code/ide/handlers/session_list_handler.rb +41 -0
- data/lib/rubyn_code/ide/handlers/session_reset_handler.rb +31 -0
- data/lib/rubyn_code/ide/handlers/session_resume_handler.rb +42 -0
- data/lib/rubyn_code/ide/handlers/shutdown_handler.rb +37 -0
- data/lib/rubyn_code/ide/handlers.rb +76 -0
- data/lib/rubyn_code/ide/protocol.rb +111 -0
- data/lib/rubyn_code/ide/server.rb +186 -0
- data/lib/rubyn_code/index/codebase_index.rb +311 -0
- data/lib/rubyn_code/learning/extractor.rb +65 -82
- data/lib/rubyn_code/learning/injector.rb +22 -23
- data/lib/rubyn_code/learning/instinct.rb +71 -42
- data/lib/rubyn_code/learning/shortcut.rb +95 -0
- data/lib/rubyn_code/llm/adapters/anthropic.rb +274 -0
- data/lib/rubyn_code/llm/adapters/anthropic_compatible.rb +60 -0
- data/lib/rubyn_code/llm/adapters/anthropic_streaming.rb +215 -0
- data/lib/rubyn_code/llm/adapters/base.rb +35 -0
- data/lib/rubyn_code/llm/adapters/json_parsing.rb +21 -0
- data/lib/rubyn_code/llm/adapters/openai.rb +246 -0
- data/lib/rubyn_code/llm/adapters/openai_compatible.rb +50 -0
- data/lib/rubyn_code/llm/adapters/openai_message_translator.rb +90 -0
- data/lib/rubyn_code/llm/adapters/openai_streaming.rb +141 -0
- data/lib/rubyn_code/llm/adapters/prompt_caching.rb +60 -0
- data/lib/rubyn_code/llm/client.rb +75 -247
- data/lib/rubyn_code/llm/model_router.rb +237 -0
- data/lib/rubyn_code/llm/streaming.rb +4 -227
- data/lib/rubyn_code/mcp/client.rb +1 -1
- data/lib/rubyn_code/mcp/config.rb +10 -12
- data/lib/rubyn_code/mcp/sse_transport.rb +15 -13
- data/lib/rubyn_code/mcp/stdio_transport.rb +16 -18
- data/lib/rubyn_code/mcp/tool_bridge.rb +31 -62
- data/lib/rubyn_code/memory/search.rb +1 -0
- data/lib/rubyn_code/memory/session_persistence.rb +59 -58
- data/lib/rubyn_code/memory/store.rb +42 -55
- data/lib/rubyn_code/observability/budget_enforcer.rb +46 -32
- data/lib/rubyn_code/observability/cost_calculator.rb +32 -8
- data/lib/rubyn_code/observability/skill_analytics.rb +116 -0
- data/lib/rubyn_code/observability/token_analytics.rb +130 -0
- data/lib/rubyn_code/observability/usage_reporter.rb +79 -61
- data/lib/rubyn_code/output/diff_renderer.rb +102 -77
- data/lib/rubyn_code/output/formatter.rb +11 -11
- data/lib/rubyn_code/permissions/policy.rb +11 -13
- data/lib/rubyn_code/permissions/prompter.rb +8 -9
- data/lib/rubyn_code/protocols/plan_approval.rb +25 -20
- data/lib/rubyn_code/self_test.rb +315 -0
- data/lib/rubyn_code/skills/catalog.rb +66 -0
- data/lib/rubyn_code/skills/document.rb +33 -29
- data/lib/rubyn_code/skills/loader.rb +43 -0
- data/lib/rubyn_code/skills/ttl_manager.rb +100 -0
- data/lib/rubyn_code/sub_agents/runner.rb +20 -25
- data/lib/rubyn_code/tasks/dag.rb +25 -24
- data/lib/rubyn_code/tasks/models.rb +1 -0
- data/lib/rubyn_code/tools/ask_user.rb +44 -0
- data/lib/rubyn_code/tools/background_run.rb +2 -1
- data/lib/rubyn_code/tools/base.rb +39 -32
- data/lib/rubyn_code/tools/bash.rb +7 -1
- data/lib/rubyn_code/tools/edit_file.rb +130 -17
- data/lib/rubyn_code/tools/executor.rb +130 -25
- data/lib/rubyn_code/tools/file_cache.rb +95 -0
- data/lib/rubyn_code/tools/git_commit.rb +12 -10
- data/lib/rubyn_code/tools/git_log.rb +12 -10
- data/lib/rubyn_code/tools/glob.rb +29 -7
- data/lib/rubyn_code/tools/grep.rb +8 -1
- data/lib/rubyn_code/tools/ide_diagnostics.rb +51 -0
- data/lib/rubyn_code/tools/ide_symbols.rb +53 -0
- data/lib/rubyn_code/tools/load_skill.rb +13 -6
- data/lib/rubyn_code/tools/memory_search.rb +14 -13
- data/lib/rubyn_code/tools/memory_write.rb +2 -1
- data/lib/rubyn_code/tools/output_compressor.rb +190 -0
- data/lib/rubyn_code/tools/read_file.rb +17 -6
- data/lib/rubyn_code/tools/registry.rb +11 -0
- data/lib/rubyn_code/tools/review_pr.rb +127 -80
- data/lib/rubyn_code/tools/run_specs.rb +26 -15
- data/lib/rubyn_code/tools/schema.rb +4 -10
- data/lib/rubyn_code/tools/spawn_agent.rb +113 -82
- data/lib/rubyn_code/tools/spawn_teammate.rb +107 -64
- data/lib/rubyn_code/tools/spec_output_parser.rb +118 -0
- data/lib/rubyn_code/tools/task.rb +17 -17
- data/lib/rubyn_code/tools/web_fetch.rb +62 -47
- data/lib/rubyn_code/tools/web_search.rb +66 -48
- data/lib/rubyn_code/tools/write_file.rb +76 -1
- data/lib/rubyn_code/version.rb +1 -1
- data/lib/rubyn_code.rb +62 -1
- data/skills/rubyn_self_test.md +133 -0
- metadata +83 -1
|
@@ -5,31 +5,118 @@ module RubynCode
|
|
|
5
5
|
module Commands
|
|
6
6
|
class Model < Base
|
|
7
7
|
def self.command_name = '/model'
|
|
8
|
-
def self.description = 'Show or switch model (/model [
|
|
9
|
-
|
|
10
|
-
KNOWN_MODELS = %w[
|
|
11
|
-
claude-haiku-4-5
|
|
12
|
-
claude-sonnet-4-20250514
|
|
13
|
-
claude-opus-4-20250514
|
|
14
|
-
].freeze
|
|
8
|
+
def self.description = 'Show or switch model (/model [provider:model])'
|
|
15
9
|
|
|
16
10
|
def execute(args, ctx)
|
|
17
11
|
name = args.first
|
|
12
|
+
return show_current(ctx) unless name
|
|
13
|
+
|
|
14
|
+
provider, model = parse_model_arg(name)
|
|
15
|
+
switch_model(provider, model, ctx)
|
|
16
|
+
end
|
|
18
17
|
|
|
19
|
-
|
|
20
|
-
unless KNOWN_MODELS.include?(name)
|
|
21
|
-
ctx.renderer.warning("Unknown model: #{name}")
|
|
22
|
-
ctx.renderer.info("Known models: #{KNOWN_MODELS.join(', ')}")
|
|
23
|
-
return
|
|
24
|
-
end
|
|
18
|
+
private
|
|
25
19
|
|
|
26
|
-
|
|
27
|
-
|
|
20
|
+
# Parse "provider:model" or just "model".
|
|
21
|
+
# Examples:
|
|
22
|
+
# "openai:gpt-4o" → ["openai", "gpt-4o"]
|
|
23
|
+
# "claude-sonnet-4-20250514" → [nil, "claude-sonnet-4-20250514"]
|
|
24
|
+
# "anthropic:" → ["anthropic", nil]
|
|
25
|
+
def parse_model_arg(arg)
|
|
26
|
+
return [arg.chomp(':'), nil] if arg.end_with?(':')
|
|
27
|
+
return [Regexp.last_match(1), Regexp.last_match(2)] if arg.match(/\A([^:]+):(.+)\z/)
|
|
28
|
+
|
|
29
|
+
[nil, arg]
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def switch_model(provider, model, ctx)
|
|
33
|
+
if provider
|
|
34
|
+
switch_provider_and_model(provider, model, ctx)
|
|
28
35
|
else
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
36
|
+
switch_model_only(model, ctx)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def switch_provider_and_model(provider, model, ctx)
|
|
41
|
+
validate_model_for_provider!(provider, model, ctx) if model
|
|
42
|
+
ctx.renderer.info("Switched to provider: #{provider}#{", model: #{model}" if model}")
|
|
43
|
+
{ action: :set_provider, provider: provider, model: model }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def switch_model_only(model, ctx)
|
|
47
|
+
unless known_model?(model, ctx)
|
|
48
|
+
ctx.renderer.warning("Unknown model: #{model}")
|
|
49
|
+
show_available(ctx)
|
|
50
|
+
return
|
|
32
51
|
end
|
|
52
|
+
|
|
53
|
+
ctx.renderer.info("Model switched to #{model}")
|
|
54
|
+
{ action: :set_model, model: model }
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def validate_model_for_provider!(provider, model, ctx)
|
|
58
|
+
adapter_models = models_for_provider(provider)
|
|
59
|
+
return if adapter_models.empty? # Unknown provider — can't validate
|
|
60
|
+
return if adapter_models.include?(model)
|
|
61
|
+
|
|
62
|
+
ctx.renderer.warning("Unknown model '#{model}' for #{provider}. Known: #{adapter_models.join(', ')}")
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def show_current(ctx)
|
|
66
|
+
client = ctx.llm_client
|
|
67
|
+
provider = client.provider_name
|
|
68
|
+
current = client.model
|
|
69
|
+
ctx.renderer.info("Provider: #{provider}")
|
|
70
|
+
ctx.renderer.info("Current model: #{current}")
|
|
71
|
+
ctx.renderer.info("Available: #{client.models.join(', ')}")
|
|
72
|
+
show_other_providers(provider, ctx)
|
|
73
|
+
ctx.renderer.info('Tip: /model provider:model to switch providers (e.g., /model openai:gpt-4o)')
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def show_other_providers(current_provider, ctx)
|
|
77
|
+
others = other_provider_entries(current_provider)
|
|
78
|
+
return if others.empty?
|
|
79
|
+
|
|
80
|
+
ctx.renderer.info('')
|
|
81
|
+
ctx.renderer.info('Other providers:')
|
|
82
|
+
others.each { |label| ctx.renderer.info(" #{label}") }
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def other_provider_entries(current_provider)
|
|
86
|
+
providers = Config::Settings.new.data['providers']
|
|
87
|
+
return [] unless providers.is_a?(Hash)
|
|
88
|
+
|
|
89
|
+
providers.reject { |name, _| name == current_provider }.map do |name, cfg|
|
|
90
|
+
models = extract_config_models(cfg)
|
|
91
|
+
models.empty? ? name : "#{name}: #{models.join(', ')}"
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def show_available(ctx)
|
|
96
|
+
client = ctx.llm_client
|
|
97
|
+
ctx.renderer.info("Available: #{client.models.join(', ')}")
|
|
98
|
+
ctx.renderer.info('Tip: /model provider:model to switch providers (e.g., /model openai:gpt-4o)')
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def known_model?(model, ctx)
|
|
102
|
+
ctx.llm_client.models.include?(model)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def models_for_provider(provider)
|
|
106
|
+
case provider
|
|
107
|
+
when 'anthropic' then LLM::Adapters::Anthropic::AVAILABLE_MODELS
|
|
108
|
+
when 'openai' then LLM::Adapters::OpenAI::AVAILABLE_MODELS
|
|
109
|
+
else
|
|
110
|
+
cfg = Config::Settings.new.provider_config(provider)
|
|
111
|
+
extract_config_models(cfg)
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def extract_config_models(cfg)
|
|
116
|
+
raw = cfg&.dig('models')
|
|
117
|
+
return [] unless raw
|
|
118
|
+
|
|
119
|
+
raw.is_a?(Hash) ? raw.values : Array(raw)
|
|
33
120
|
end
|
|
34
121
|
end
|
|
35
122
|
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubynCode
|
|
4
|
+
module CLI
|
|
5
|
+
module Commands
|
|
6
|
+
# Saves the current session and starts a fresh conversation.
|
|
7
|
+
# Like pressing Escape in Claude Code — clears context without quitting.
|
|
8
|
+
class NewSession < Base
|
|
9
|
+
def self.command_name = '/new'
|
|
10
|
+
def self.description = 'Save current session and start a fresh conversation'
|
|
11
|
+
def self.aliases = ['/reset'].freeze
|
|
12
|
+
|
|
13
|
+
def execute(_args, ctx)
|
|
14
|
+
save_current_session(ctx)
|
|
15
|
+
clear_conversation(ctx)
|
|
16
|
+
new_session_id = generate_session_id
|
|
17
|
+
|
|
18
|
+
ctx.renderer.info('Session saved. Starting fresh.')
|
|
19
|
+
ctx.renderer.info("New session: #{new_session_id[0..7]}")
|
|
20
|
+
|
|
21
|
+
{ action: :new_session, session_id: new_session_id }
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def save_current_session(ctx)
|
|
27
|
+
ctx.session_persistence.save_session(
|
|
28
|
+
session_id: ctx.session_id,
|
|
29
|
+
project_path: ctx.project_root,
|
|
30
|
+
messages: ctx.conversation.messages,
|
|
31
|
+
model: Config::Defaults::DEFAULT_MODEL
|
|
32
|
+
)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def clear_conversation(ctx)
|
|
36
|
+
ctx.conversation.clear!
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def generate_session_id
|
|
40
|
+
SecureRandom.hex(16)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubynCode
|
|
4
|
+
module CLI
|
|
5
|
+
module Commands
|
|
6
|
+
class Provider < Base
|
|
7
|
+
def self.command_name = '/provider'
|
|
8
|
+
def self.description = 'Manage providers (/provider add|list|set-key)'
|
|
9
|
+
|
|
10
|
+
USAGE_LINES = [
|
|
11
|
+
'Usage:',
|
|
12
|
+
' /provider list List configured providers',
|
|
13
|
+
' /provider add <name> <base_url> [opts] Add a provider',
|
|
14
|
+
' /provider set-key <name> <key> Store an API key',
|
|
15
|
+
'',
|
|
16
|
+
'Options for add:',
|
|
17
|
+
' --key <api_key> API key (stored securely in tokens.yml)',
|
|
18
|
+
' --format <openai|anthropic> API format (default: openai)',
|
|
19
|
+
' --models <m1,m2,...> Comma-separated model names',
|
|
20
|
+
'',
|
|
21
|
+
'Example:',
|
|
22
|
+
' /provider add groq https://api.groq.com/openai/v1 --key gsk-xxx --models llama-3.3-70b'
|
|
23
|
+
].freeze
|
|
24
|
+
|
|
25
|
+
FLAG_KEYS = { '--format' => :api_format, '--env-key' => :env_key, '--key' => :key }.freeze
|
|
26
|
+
|
|
27
|
+
def execute(args, ctx)
|
|
28
|
+
return show_usage(ctx) if args.empty?
|
|
29
|
+
|
|
30
|
+
case args.first
|
|
31
|
+
when 'add' then add_provider(args[1..], ctx)
|
|
32
|
+
when 'list' then list_providers(ctx)
|
|
33
|
+
when 'set-key' then set_key(args[1..], ctx)
|
|
34
|
+
else show_usage(ctx)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def add_provider(args, ctx)
|
|
41
|
+
name = args.shift
|
|
42
|
+
base_url = args.shift
|
|
43
|
+
return ctx.renderer.warning('Usage: /provider add <name> <base_url> [options]') unless name && base_url
|
|
44
|
+
|
|
45
|
+
opts = parse_flags(args)
|
|
46
|
+
persist_provider(name, base_url, opts)
|
|
47
|
+
confirm_added(name, base_url, opts, ctx)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def persist_provider(name, base_url, opts)
|
|
51
|
+
Config::Settings.new.add_provider(
|
|
52
|
+
name, base_url: base_url, env_key: opts[:env_key],
|
|
53
|
+
models: opts[:models], api_format: opts[:api_format]
|
|
54
|
+
)
|
|
55
|
+
Auth::TokenStore.save_provider_key(name, opts[:key]) if opts[:key]
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def confirm_added(name, base_url, opts, ctx) # rubocop:disable Metrics/AbcSize -- sequential output lines
|
|
59
|
+
ctx.renderer.success("Provider '#{name}' added (#{opts[:api_format] || 'openai'} format)")
|
|
60
|
+
ctx.renderer.info(" base_url: #{base_url}")
|
|
61
|
+
ctx.renderer.info(' api_key: stored') if opts[:key]
|
|
62
|
+
ctx.renderer.info(" models: #{opts[:models].join(', ')}") unless opts[:models].empty?
|
|
63
|
+
ctx.renderer.info("Switch with: /model #{name}:#{opts[:models].first || '<model>'}")
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def set_key(args, ctx)
|
|
67
|
+
name = args[0]
|
|
68
|
+
key = args[1]
|
|
69
|
+
return ctx.renderer.warning('Usage: /provider set-key <name> <key>') unless name && key
|
|
70
|
+
|
|
71
|
+
Auth::TokenStore.save_provider_key(name, key)
|
|
72
|
+
ctx.renderer.success("API key stored for '#{name}'")
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def list_providers(ctx)
|
|
76
|
+
providers = Config::Settings.new.data['providers']
|
|
77
|
+
return ctx.renderer.info('No providers configured.') unless providers.is_a?(Hash) && providers.any?
|
|
78
|
+
|
|
79
|
+
providers.each { |name, cfg| ctx.renderer.info(" #{format_provider(name, cfg)}") }
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def format_provider(name, cfg)
|
|
83
|
+
format_label = cfg.is_a?(Hash) && cfg['api_format'] ? " (#{cfg['api_format']})" : ''
|
|
84
|
+
models = extract_models(cfg)
|
|
85
|
+
model_label = models.empty? ? '' : " — #{models.join(', ')}"
|
|
86
|
+
"#{name}#{format_label}#{model_label}"
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def parse_flags(args)
|
|
90
|
+
opts = { models: [], env_key: nil, api_format: nil, key: nil }
|
|
91
|
+
idx = 0
|
|
92
|
+
idx = parse_single_flag(args, idx, opts) while idx < args.length
|
|
93
|
+
opts
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def parse_single_flag(args, idx, opts)
|
|
97
|
+
flag = args[idx]
|
|
98
|
+
return idx + 1 unless FLAG_KEYS.key?(flag) || flag == '--models'
|
|
99
|
+
return parse_models_flag(args, idx, opts) if flag == '--models'
|
|
100
|
+
|
|
101
|
+
opts[FLAG_KEYS[flag]] = args[idx + 1]
|
|
102
|
+
idx + 2
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def parse_models_flag(args, idx, opts)
|
|
106
|
+
opts[:models] = args[idx + 1]&.split(',')&.map(&:strip) || []
|
|
107
|
+
idx + 2
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def extract_models(cfg)
|
|
111
|
+
raw = cfg.is_a?(Hash) ? cfg['models'] : nil
|
|
112
|
+
return [] unless raw
|
|
113
|
+
|
|
114
|
+
raw.is_a?(Hash) ? raw.values : Array(raw)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def show_usage(ctx)
|
|
118
|
+
USAGE_LINES.each { |line| ctx.renderer.info(line) }
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
@@ -8,7 +8,13 @@ module RubynCode
|
|
|
8
8
|
def self.description = 'Load a skill or list available skills'
|
|
9
9
|
|
|
10
10
|
def execute(args, ctx)
|
|
11
|
-
|
|
11
|
+
return list_skills(ctx) if args.empty?
|
|
12
|
+
|
|
13
|
+
case args.first
|
|
14
|
+
when 'search' then search_skills(args[1..].join(' '), ctx)
|
|
15
|
+
when 'list' then list_by_category(args[1], ctx)
|
|
16
|
+
else load_skill(args.first, ctx)
|
|
17
|
+
end
|
|
12
18
|
rescue StandardError => e
|
|
13
19
|
ctx.renderer.error("Skill error: #{e.message}")
|
|
14
20
|
end
|
|
@@ -22,9 +28,52 @@ module RubynCode
|
|
|
22
28
|
end
|
|
23
29
|
|
|
24
30
|
def list_skills(ctx)
|
|
25
|
-
skills = ctx.skill_loader.catalog.
|
|
31
|
+
skills = ctx.skill_loader.catalog.available
|
|
26
32
|
ctx.renderer.info("Available skills (#{skills.size}):")
|
|
27
|
-
skills.each { |skill| puts " /#{skill}" }
|
|
33
|
+
skills.each { |skill| puts " /#{skill[:name]}: #{skill[:description]}" }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def search_skills(term, ctx) # rubocop:disable Metrics/AbcSize -- readable sequential display logic
|
|
37
|
+
if term.nil? || term.strip.empty?
|
|
38
|
+
ctx.renderer.warning('Usage: /skill search <term>')
|
|
39
|
+
return
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
results = ctx.skill_loader.catalog.search(term.strip)
|
|
43
|
+
if results.empty?
|
|
44
|
+
ctx.renderer.info("No skills found matching '#{term.strip}'")
|
|
45
|
+
return
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
ctx.renderer.info("Skills matching '#{term.strip}' (#{results.size}):")
|
|
49
|
+
display_entries(results)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def list_by_category(category, ctx) # rubocop:disable Metrics/AbcSize -- readable sequential display logic
|
|
53
|
+
catalog = ctx.skill_loader.catalog
|
|
54
|
+
return list_categories(catalog, ctx) if category.nil? || category.strip.empty?
|
|
55
|
+
|
|
56
|
+
results = catalog.by_category(category.strip)
|
|
57
|
+
if results.empty?
|
|
58
|
+
ctx.renderer.info("No skills found in category '#{category.strip}'")
|
|
59
|
+
return
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
ctx.renderer.info("Skills in '#{category.strip}' (#{results.size}):")
|
|
63
|
+
display_entries(results)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def list_categories(catalog, ctx)
|
|
67
|
+
categories = catalog.categories
|
|
68
|
+
ctx.renderer.info("Skill categories (#{categories.size}):")
|
|
69
|
+
categories.each { |cat| puts " #{cat}" }
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def display_entries(entries)
|
|
73
|
+
entries.each do |entry|
|
|
74
|
+
desc = entry[:description].to_s.empty? ? '' : " — #{entry[:description]}"
|
|
75
|
+
puts " /#{entry[:name]}#{desc}"
|
|
76
|
+
end
|
|
28
77
|
end
|
|
29
78
|
end
|
|
30
79
|
end
|
|
@@ -18,14 +18,9 @@ module RubynCode
|
|
|
18
18
|
end
|
|
19
19
|
|
|
20
20
|
def run
|
|
21
|
-
|
|
22
|
-
ensure_auth!
|
|
23
|
-
setup_database!
|
|
24
|
-
display_banner!
|
|
25
|
-
|
|
21
|
+
bootstrap!
|
|
26
22
|
daemon = build_daemon
|
|
27
23
|
daemon.start!
|
|
28
|
-
|
|
29
24
|
display_shutdown_summary(daemon)
|
|
30
25
|
rescue Interrupt
|
|
31
26
|
@renderer.info("\nShutting down daemon...")
|
|
@@ -34,10 +29,20 @@ module RubynCode
|
|
|
34
29
|
@renderer.error("Daemon failed: #{e.message}")
|
|
35
30
|
RubynCode::Debug.warn(e.backtrace&.first(5)&.join("\n")) if @options[:debug]
|
|
36
31
|
exit(1)
|
|
32
|
+
ensure
|
|
33
|
+
disconnect_mcp_clients!
|
|
37
34
|
end
|
|
38
35
|
|
|
39
36
|
private
|
|
40
37
|
|
|
38
|
+
def bootstrap!
|
|
39
|
+
ensure_home_dir!
|
|
40
|
+
ensure_auth!
|
|
41
|
+
setup_database!
|
|
42
|
+
setup_mcp_servers!
|
|
43
|
+
display_banner!
|
|
44
|
+
end
|
|
45
|
+
|
|
41
46
|
def build_daemon
|
|
42
47
|
Autonomous::Daemon.new(
|
|
43
48
|
agent_name: @daemon_opts[:agent_name],
|
|
@@ -79,13 +84,16 @@ module RubynCode
|
|
|
79
84
|
end
|
|
80
85
|
|
|
81
86
|
def ensure_auth!
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
87
|
+
provider = @daemon_opts.fetch(:provider, Config::Defaults::DEFAULT_PROVIDER)
|
|
88
|
+
|
|
89
|
+
unless Auth::TokenStore.load_for_provider(provider)
|
|
90
|
+
@renderer.error("No valid authentication found for provider '#{provider}'.")
|
|
91
|
+
env_key = Config::Defaults::PROVIDER_ENV_KEYS.fetch(provider, "#{provider.upcase}_API_KEY")
|
|
92
|
+
@renderer.info("Set #{env_key} or run `rubyn-code --auth`.")
|
|
85
93
|
exit(1)
|
|
86
94
|
end
|
|
87
95
|
|
|
88
|
-
@llm_client = LLM::Client.new
|
|
96
|
+
@llm_client = LLM::Client.new(provider: provider)
|
|
89
97
|
end
|
|
90
98
|
|
|
91
99
|
def setup_database!
|
|
@@ -96,9 +104,18 @@ module RubynCode
|
|
|
96
104
|
end
|
|
97
105
|
|
|
98
106
|
def display_banner!
|
|
107
|
+
display_banner_header!
|
|
108
|
+
display_banner_details!
|
|
109
|
+
display_banner_footer!
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def display_banner_header!
|
|
99
113
|
@renderer.info('╔══════════════════════════════════════╗')
|
|
100
114
|
@renderer.info('║ GOLEM Daemon Starting ║')
|
|
101
115
|
@renderer.info('╚══════════════════════════════════════╝')
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def display_banner_details!
|
|
102
119
|
@renderer.info(" Agent: #{@daemon_opts[:agent_name]}")
|
|
103
120
|
@renderer.info(" Role: #{@daemon_opts[:role]}")
|
|
104
121
|
@renderer.info(" Project: #{@project_root}")
|
|
@@ -106,6 +123,9 @@ module RubynCode
|
|
|
106
123
|
@renderer.info(" Max cost: $#{@daemon_opts[:max_cost]}")
|
|
107
124
|
@renderer.info(" Idle timeout: #{@daemon_opts[:idle_timeout]}s")
|
|
108
125
|
@renderer.info(" Poll interval: #{@daemon_opts[:poll_interval]}s")
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def display_banner_footer!
|
|
109
129
|
@renderer.info('')
|
|
110
130
|
@renderer.info('Waiting for tasks... (Ctrl-C to stop)')
|
|
111
131
|
@renderer.info('Seed tasks via the REPL: /tasks or the task tool.')
|
|
@@ -122,7 +142,40 @@ module RubynCode
|
|
|
122
142
|
@renderer.info('╚══════════════════════════════════════╝')
|
|
123
143
|
@renderer.info(" Final state: #{status[:state]}")
|
|
124
144
|
@renderer.info(" Tasks completed: #{status[:runs_completed]}")
|
|
125
|
-
@renderer.info(
|
|
145
|
+
@renderer.info(format(' Total cost: $%.4f', status[:total_cost]))
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# ── MCP Server Wiring ─────────────────────────────────────────
|
|
149
|
+
|
|
150
|
+
def setup_mcp_servers!
|
|
151
|
+
@mcp_clients = []
|
|
152
|
+
server_configs = MCP::Config.load(@project_root)
|
|
153
|
+
return if server_configs.empty?
|
|
154
|
+
|
|
155
|
+
server_configs.each do |config|
|
|
156
|
+
connect_mcp_server(config)
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def connect_mcp_server(config)
|
|
161
|
+
client = MCP::Client.from_config(config)
|
|
162
|
+
client.connect!
|
|
163
|
+
MCP::ToolBridge.bridge(client)
|
|
164
|
+
@mcp_clients << client
|
|
165
|
+
@renderer.info(" MCP server '#{config[:name]}' connected (#{client.tools.size} tools)")
|
|
166
|
+
rescue StandardError => e
|
|
167
|
+
warn "[MCP] Failed to connect '#{config[:name]}': #{e.message}"
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def disconnect_mcp_clients!
|
|
171
|
+
return if @mcp_clients.nil? || @mcp_clients.empty?
|
|
172
|
+
|
|
173
|
+
@mcp_clients.each do |client|
|
|
174
|
+
client.disconnect!
|
|
175
|
+
rescue StandardError => e
|
|
176
|
+
warn "[MCP] Error disconnecting '#{client.name}': #{e.message}"
|
|
177
|
+
end
|
|
178
|
+
@mcp_clients.clear
|
|
126
179
|
end
|
|
127
180
|
end
|
|
128
181
|
end
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'tty-prompt'
|
|
4
|
+
require 'yaml'
|
|
5
|
+
require 'fileutils'
|
|
6
|
+
|
|
7
|
+
module RubynCode
|
|
8
|
+
module CLI
|
|
9
|
+
# Guided first-run setup wizard.
|
|
10
|
+
#
|
|
11
|
+
# Runs when no ~/.rubyn-code/config.yml exists (first launch).
|
|
12
|
+
# Walks the user through provider selection, API key configuration,
|
|
13
|
+
# and default budget setup.
|
|
14
|
+
#
|
|
15
|
+
# Skippable via --skip-setup flag or RUBYN_SKIP_SETUP=1 env var.
|
|
16
|
+
class FirstRun
|
|
17
|
+
PROVIDERS = {
|
|
18
|
+
'Anthropic (recommended)' => 'anthropic',
|
|
19
|
+
'OpenAI' => 'openai',
|
|
20
|
+
'Other (configure later)' => 'other'
|
|
21
|
+
}.freeze
|
|
22
|
+
|
|
23
|
+
DEFAULT_BUDGET = 5.0
|
|
24
|
+
|
|
25
|
+
def initialize(config_path: Config::Defaults::CONFIG_FILE, prompt: nil)
|
|
26
|
+
@config_path = config_path
|
|
27
|
+
@tty_prompt = prompt
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Returns true if first-run setup should be triggered.
|
|
31
|
+
def self.needed?(config_path: Config::Defaults::CONFIG_FILE)
|
|
32
|
+
!File.exist?(config_path)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Returns true if the user opted to skip setup.
|
|
36
|
+
def self.skipped?(skip_flag: false)
|
|
37
|
+
skip_flag || ENV['RUBYN_SKIP_SETUP'] == '1'
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def run
|
|
41
|
+
display_welcome
|
|
42
|
+
provider = ask_provider
|
|
43
|
+
configure_api_key(provider)
|
|
44
|
+
budget = ask_budget
|
|
45
|
+
write_config(provider, budget)
|
|
46
|
+
display_summary
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def tty_prompt
|
|
52
|
+
@tty_prompt ||= TTY::Prompt.new
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def display_welcome
|
|
56
|
+
puts
|
|
57
|
+
puts "\e[1;36m#{'=' * 50}\e[0m"
|
|
58
|
+
puts "\e[1;36m Welcome to Rubyn Code!\e[0m"
|
|
59
|
+
puts "\e[1;36m Ruby & Rails Agentic Coding Assistant\e[0m"
|
|
60
|
+
puts "\e[1;36m#{'=' * 50}\e[0m"
|
|
61
|
+
puts
|
|
62
|
+
puts " Let's get you set up. This will only take a moment."
|
|
63
|
+
puts
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def ask_provider
|
|
67
|
+
tty_prompt.select('Which AI provider would you like to use?', PROVIDERS)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def configure_api_key(provider)
|
|
71
|
+
case provider
|
|
72
|
+
when 'anthropic'
|
|
73
|
+
configure_anthropic
|
|
74
|
+
when 'openai'
|
|
75
|
+
configure_openai
|
|
76
|
+
when 'other'
|
|
77
|
+
puts
|
|
78
|
+
puts ' You can configure a custom provider later in ~/.rubyn-code/config.yml'
|
|
79
|
+
puts
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def configure_anthropic
|
|
84
|
+
puts
|
|
85
|
+
puts ' Anthropic authentication options:'
|
|
86
|
+
puts ' 1. Set ANTHROPIC_API_KEY environment variable'
|
|
87
|
+
puts ' 2. Run `rubyn-code --auth` for OAuth (if you have a Claude account)'
|
|
88
|
+
puts
|
|
89
|
+
|
|
90
|
+
has_key = ENV.key?('ANTHROPIC_API_KEY')
|
|
91
|
+
if has_key
|
|
92
|
+
puts " \e[32m✓\e[0m ANTHROPIC_API_KEY is already set."
|
|
93
|
+
else
|
|
94
|
+
puts " \e[33m!\e[0m ANTHROPIC_API_KEY is not set."
|
|
95
|
+
puts ' Add it to your shell profile or set it before running rubyn-code.'
|
|
96
|
+
end
|
|
97
|
+
puts
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def configure_openai
|
|
101
|
+
puts
|
|
102
|
+
has_key = ENV.key?('OPENAI_API_KEY')
|
|
103
|
+
if has_key
|
|
104
|
+
puts " \e[32m✓\e[0m OPENAI_API_KEY is already set."
|
|
105
|
+
else
|
|
106
|
+
puts " \e[33m!\e[0m OPENAI_API_KEY is not set."
|
|
107
|
+
puts ' Add it to your shell profile or set it before running rubyn-code.'
|
|
108
|
+
end
|
|
109
|
+
puts
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def ask_budget
|
|
113
|
+
tty_prompt.ask(
|
|
114
|
+
'Session budget in USD?',
|
|
115
|
+
default: DEFAULT_BUDGET.to_s,
|
|
116
|
+
convert: :float
|
|
117
|
+
)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def write_config(provider, budget)
|
|
121
|
+
dir = File.dirname(@config_path)
|
|
122
|
+
FileUtils.mkdir_p(dir, mode: 0o700) unless File.directory?(dir)
|
|
123
|
+
|
|
124
|
+
model = default_model(provider)
|
|
125
|
+
data = {
|
|
126
|
+
'provider' => provider == 'other' ? 'anthropic' : provider,
|
|
127
|
+
'model' => model,
|
|
128
|
+
'session_budget_usd' => budget,
|
|
129
|
+
'providers' => Config::Settings::DEFAULT_PROVIDER_MODELS.transform_values(&:dup)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
File.write(@config_path, YAML.dump(data))
|
|
133
|
+
File.chmod(0o600, @config_path)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def default_model(provider)
|
|
137
|
+
return 'gpt-5.4' if provider == 'openai'
|
|
138
|
+
|
|
139
|
+
'claude-opus-4-6'
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def display_summary
|
|
143
|
+
puts
|
|
144
|
+
puts "\e[1;32m Setup complete!\e[0m"
|
|
145
|
+
puts
|
|
146
|
+
puts ' Quick-start commands:'
|
|
147
|
+
puts ' /help — Show all available commands'
|
|
148
|
+
puts ' /skill — List coding skills'
|
|
149
|
+
puts ' /doctor — Check environment health'
|
|
150
|
+
puts ' /cost — View session costs'
|
|
151
|
+
puts ' /compact — Compress conversation context'
|
|
152
|
+
puts ' /quit — Exit'
|
|
153
|
+
puts
|
|
154
|
+
puts " Config saved to: #{@config_path}"
|
|
155
|
+
puts
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|