rubyn-code 0.3.0 → 0.5.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.
Files changed (103) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +263 -21
  3. data/db/migrations/013_add_failed_status_to_tasks.rb +51 -0
  4. data/lib/rubyn_code/agent/conversation.rb +34 -4
  5. data/lib/rubyn_code/agent/dynamic_tool_schema.rb +57 -3
  6. data/lib/rubyn_code/agent/llm_caller.rb +11 -1
  7. data/lib/rubyn_code/agent/loop.rb +14 -3
  8. data/lib/rubyn_code/agent/response_modes.rb +2 -1
  9. data/lib/rubyn_code/agent/system_prompt_builder.rb +49 -4
  10. data/lib/rubyn_code/agent/tool_processor.rb +25 -3
  11. data/lib/rubyn_code/auth/key_encryption.rb +118 -0
  12. data/lib/rubyn_code/auth/token_store.rb +50 -9
  13. data/lib/rubyn_code/autonomous/daemon.rb +117 -14
  14. data/lib/rubyn_code/autonomous/idle_poller.rb +0 -20
  15. data/lib/rubyn_code/autonomous/task_claimer.rb +17 -11
  16. data/lib/rubyn_code/cli/app.rb +116 -11
  17. data/lib/rubyn_code/cli/commands/doctor.rb +73 -0
  18. data/lib/rubyn_code/cli/commands/install_skills.rb +44 -0
  19. data/lib/rubyn_code/cli/commands/list_skills.rb +149 -0
  20. data/lib/rubyn_code/cli/commands/mcp.rb +77 -0
  21. data/lib/rubyn_code/cli/commands/model.rb +32 -2
  22. data/lib/rubyn_code/cli/commands/provider.rb +124 -0
  23. data/lib/rubyn_code/cli/commands/remove_skills.rb +35 -0
  24. data/lib/rubyn_code/cli/commands/skill.rb +54 -3
  25. data/lib/rubyn_code/cli/commands/skills.rb +104 -0
  26. data/lib/rubyn_code/cli/daemon_runner.rb +36 -0
  27. data/lib/rubyn_code/cli/first_run.rb +159 -0
  28. data/lib/rubyn_code/cli/repl.rb +15 -0
  29. data/lib/rubyn_code/cli/repl_commands.rb +3 -1
  30. data/lib/rubyn_code/cli/repl_lifecycle.rb +1 -0
  31. data/lib/rubyn_code/cli/repl_setup.rb +74 -1
  32. data/lib/rubyn_code/config/defaults.rb +3 -0
  33. data/lib/rubyn_code/config/schema.json +49 -0
  34. data/lib/rubyn_code/config/settings.rb +12 -6
  35. data/lib/rubyn_code/config/validator.rb +63 -0
  36. data/lib/rubyn_code/context/context_budget.rb +18 -2
  37. data/lib/rubyn_code/context/context_collapse.rb +34 -4
  38. data/lib/rubyn_code/context/manager.rb +37 -3
  39. data/lib/rubyn_code/context/manual_compact.rb +1 -1
  40. data/lib/rubyn_code/hooks/registry.rb +4 -0
  41. data/lib/rubyn_code/ide/adapters/tool_output.rb +330 -0
  42. data/lib/rubyn_code/ide/client.rb +110 -0
  43. data/lib/rubyn_code/ide/handlers/accept_edit_handler.rb +35 -0
  44. data/lib/rubyn_code/ide/handlers/approve_tool_use_handler.rb +34 -0
  45. data/lib/rubyn_code/ide/handlers/cancel_handler.rb +41 -0
  46. data/lib/rubyn_code/ide/handlers/config_get_handler.rb +63 -0
  47. data/lib/rubyn_code/ide/handlers/config_set_handler.rb +86 -0
  48. data/lib/rubyn_code/ide/handlers/initialize_handler.rb +79 -0
  49. data/lib/rubyn_code/ide/handlers/models_list_handler.rb +39 -0
  50. data/lib/rubyn_code/ide/handlers/prompt_handler.rb +218 -0
  51. data/lib/rubyn_code/ide/handlers/review_handler.rb +127 -0
  52. data/lib/rubyn_code/ide/handlers/session_fork_handler.rb +49 -0
  53. data/lib/rubyn_code/ide/handlers/session_list_handler.rb +41 -0
  54. data/lib/rubyn_code/ide/handlers/session_reset_handler.rb +31 -0
  55. data/lib/rubyn_code/ide/handlers/session_resume_handler.rb +42 -0
  56. data/lib/rubyn_code/ide/handlers/shutdown_handler.rb +37 -0
  57. data/lib/rubyn_code/ide/handlers.rb +76 -0
  58. data/lib/rubyn_code/ide/protocol.rb +112 -0
  59. data/lib/rubyn_code/ide/server.rb +186 -0
  60. data/lib/rubyn_code/index/codebase_index.rb +69 -2
  61. data/lib/rubyn_code/learning/extractor.rb +4 -2
  62. data/lib/rubyn_code/llm/adapters/anthropic.rb +6 -2
  63. data/lib/rubyn_code/llm/adapters/anthropic_compatible.rb +60 -0
  64. data/lib/rubyn_code/llm/adapters/openai_compatible.rb +6 -2
  65. data/lib/rubyn_code/llm/client.rb +29 -4
  66. data/lib/rubyn_code/llm/model_router.rb +2 -1
  67. data/lib/rubyn_code/mcp/config.rb +2 -1
  68. data/lib/rubyn_code/mcp/tool_bridge.rb +1 -1
  69. data/lib/rubyn_code/memory/search.rb +1 -0
  70. data/lib/rubyn_code/observability/usage_reporter.rb +4 -2
  71. data/lib/rubyn_code/output/diff_renderer.rb +3 -2
  72. data/lib/rubyn_code/self_test.rb +316 -0
  73. data/lib/rubyn_code/skills/auto_suggest.rb +131 -0
  74. data/lib/rubyn_code/skills/catalog.rb +76 -0
  75. data/lib/rubyn_code/skills/document.rb +8 -2
  76. data/lib/rubyn_code/skills/gemfile_parser.rb +40 -0
  77. data/lib/rubyn_code/skills/loader.rb +43 -0
  78. data/lib/rubyn_code/skills/matcher.rb +89 -0
  79. data/lib/rubyn_code/skills/pack_context.rb +163 -0
  80. data/lib/rubyn_code/skills/pack_installer.rb +194 -0
  81. data/lib/rubyn_code/skills/pack_manager.rb +230 -0
  82. data/lib/rubyn_code/skills/registry_autoload.rb +112 -0
  83. data/lib/rubyn_code/skills/registry_client.rb +241 -0
  84. data/lib/rubyn_code/tasks/models.rb +1 -0
  85. data/lib/rubyn_code/tools/base.rb +13 -0
  86. data/lib/rubyn_code/tools/bash.rb +5 -0
  87. data/lib/rubyn_code/tools/edit_file.rb +62 -5
  88. data/lib/rubyn_code/tools/executor.rb +65 -8
  89. data/lib/rubyn_code/tools/glob.rb +6 -0
  90. data/lib/rubyn_code/tools/grep.rb +7 -0
  91. data/lib/rubyn_code/tools/ide_diagnostics.rb +53 -0
  92. data/lib/rubyn_code/tools/ide_symbols.rb +55 -0
  93. data/lib/rubyn_code/tools/load_skill.rb +2 -1
  94. data/lib/rubyn_code/tools/output_compressor.rb +9 -7
  95. data/lib/rubyn_code/tools/read_file.rb +6 -0
  96. data/lib/rubyn_code/tools/registry.rb +11 -0
  97. data/lib/rubyn_code/tools/review_pr.rb +15 -4
  98. data/lib/rubyn_code/tools/web_search.rb +2 -1
  99. data/lib/rubyn_code/tools/write_file.rb +17 -0
  100. data/lib/rubyn_code/version.rb +1 -1
  101. data/lib/rubyn_code.rb +34 -0
  102. data/skills/rubyn_self_test.md +88 -1
  103. metadata +43 -1
@@ -0,0 +1,124 @@
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
+ # -- sequential output lines
59
+ def confirm_added(name, base_url, opts, ctx)
60
+ ctx.renderer.success("Provider '#{name}' added (#{opts[:api_format] || 'openai'} format)")
61
+ ctx.renderer.info(" base_url: #{base_url}")
62
+ ctx.renderer.info(' api_key: stored') if opts[:key]
63
+ ctx.renderer.info(" models: #{opts[:models].join(', ')}") unless opts[:models].empty?
64
+ ctx.renderer.info("Switch with: /model #{name}:#{opts[:models].first || '<model>'}")
65
+ end
66
+
67
+ def set_key(args, ctx)
68
+ name = args[0]
69
+ key = args[1]
70
+ return ctx.renderer.warning('Usage: /provider set-key <name> <key>') unless name && key
71
+
72
+ Auth::TokenStore.save_provider_key(name, key)
73
+ ctx.renderer.success("API key stored for '#{name}'")
74
+ end
75
+
76
+ def list_providers(ctx)
77
+ providers = Config::Settings.new.data['providers']
78
+ return ctx.renderer.info('No providers configured.') unless providers.is_a?(Hash) && providers.any?
79
+
80
+ providers.each { |name, cfg| ctx.renderer.info(" #{format_provider(name, cfg)}") }
81
+ end
82
+
83
+ def format_provider(name, cfg)
84
+ format_label = cfg.is_a?(Hash) && cfg['api_format'] ? " (#{cfg['api_format']})" : ''
85
+ models = extract_models(cfg)
86
+ model_label = models.empty? ? '' : " — #{models.join(', ')}"
87
+ "#{name}#{format_label}#{model_label}"
88
+ end
89
+
90
+ def parse_flags(args)
91
+ opts = { models: [], env_key: nil, api_format: nil, key: nil }
92
+ idx = 0
93
+ idx = parse_single_flag(args, idx, opts) while idx < args.length
94
+ opts
95
+ end
96
+
97
+ def parse_single_flag(args, idx, opts)
98
+ flag = args[idx]
99
+ return idx + 1 unless FLAG_KEYS.key?(flag) || flag == '--models'
100
+ return parse_models_flag(args, idx, opts) if flag == '--models'
101
+
102
+ opts[FLAG_KEYS[flag]] = args[idx + 1]
103
+ idx + 2
104
+ end
105
+
106
+ def parse_models_flag(args, idx, opts)
107
+ opts[:models] = args[idx + 1]&.split(',')&.map(&:strip) || []
108
+ idx + 2
109
+ end
110
+
111
+ def extract_models(cfg)
112
+ raw = cfg.is_a?(Hash) ? cfg['models'] : nil
113
+ return [] unless raw
114
+
115
+ raw.is_a?(Hash) ? raw.values : Array(raw)
116
+ end
117
+
118
+ def show_usage(ctx)
119
+ USAGE_LINES.each { |line| ctx.renderer.info(line) }
120
+ end
121
+ end
122
+ end
123
+ end
124
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubynCode
4
+ module CLI
5
+ module Commands
6
+ class RemoveSkills < Base
7
+ def self.command_name = '/remove-skills'
8
+ def self.description = 'Remove installed skill packs'
9
+
10
+ def execute(args, ctx)
11
+ if args.empty?
12
+ ctx.renderer.warning('Usage: /remove-skills <pack-name> [pack-name ...]')
13
+ return
14
+ end
15
+
16
+ pack_manager = RubynCode::Skills::PackManager.new
17
+
18
+ args.each { |name| remove_pack(name, pack_manager, ctx) }
19
+ end
20
+
21
+ private
22
+
23
+ def remove_pack(name, pack_manager, ctx)
24
+ unless pack_manager.installed?(name)
25
+ ctx.renderer.warning("Pack '#{name}' is not installed.")
26
+ return
27
+ end
28
+
29
+ pack_manager.remove(name)
30
+ ctx.renderer.info("Removed skill pack '#{name}'.")
31
+ end
32
+ end
33
+ end
34
+ end
35
+ 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
- args.first ? load_skill(args.first, ctx) : list_skills(ctx)
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,54 @@ module RubynCode
22
28
  end
23
29
 
24
30
  def list_skills(ctx)
25
- skills = ctx.skill_loader.catalog.list
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
+ # -- readable sequential display logic
37
+ def search_skills(term, ctx)
38
+ if term.nil? || term.strip.empty?
39
+ ctx.renderer.warning('Usage: /skill search <term>')
40
+ return
41
+ end
42
+
43
+ results = ctx.skill_loader.catalog.search(term.strip)
44
+ if results.empty?
45
+ ctx.renderer.info("No skills found matching '#{term.strip}'")
46
+ return
47
+ end
48
+
49
+ ctx.renderer.info("Skills matching '#{term.strip}' (#{results.size}):")
50
+ display_entries(results)
51
+ end
52
+
53
+ # -- readable sequential display logic
54
+ def list_by_category(category, ctx)
55
+ catalog = ctx.skill_loader.catalog
56
+ return list_categories(catalog, ctx) if category.nil? || category.strip.empty?
57
+
58
+ results = catalog.by_category(category.strip)
59
+ if results.empty?
60
+ ctx.renderer.info("No skills found in category '#{category.strip}'")
61
+ return
62
+ end
63
+
64
+ ctx.renderer.info("Skills in '#{category.strip}' (#{results.size}):")
65
+ display_entries(results)
66
+ end
67
+
68
+ def list_categories(catalog, ctx)
69
+ categories = catalog.categories
70
+ ctx.renderer.info("Skill categories (#{categories.size}):")
71
+ categories.each { |cat| puts " #{cat}" }
72
+ end
73
+
74
+ def display_entries(entries)
75
+ entries.each do |entry|
76
+ desc = entry[:description].to_s.empty? ? '' : " — #{entry[:description]}"
77
+ puts " /#{entry[:name]}#{desc}"
78
+ end
28
79
  end
29
80
  end
30
81
  end
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubynCode
4
+ module CLI
5
+ module Commands
6
+ class Skills < Base
7
+ def self.command_name = '/skills'
8
+ def self.description = 'List installed skill packs or browse the registry'
9
+
10
+ def execute(args, ctx)
11
+ case args.first
12
+ when 'search' then search_registry(args[1..].join(' '), ctx)
13
+ when 'available' then list_available(ctx)
14
+ when nil, 'list' then list_installed(ctx)
15
+ else
16
+ ctx.renderer.warning(
17
+ "Unknown subcommand '#{args.first}'. Try: /skills, /skills available, /skills search <term>"
18
+ )
19
+ end
20
+ rescue RubynCode::Skills::RegistryError => e
21
+ ctx.renderer.error(e.message)
22
+ end
23
+
24
+ private
25
+
26
+ def list_installed(ctx)
27
+ packs = RubynCode::Skills::PackManager.new.installed
28
+
29
+ if packs.empty?
30
+ ctx.renderer.info(
31
+ 'No skill packs installed. Use /skills available to browse or /install-skills <name> to install.'
32
+ )
33
+ return
34
+ end
35
+
36
+ ctx.renderer.info("Installed skill packs (#{packs.size}):")
37
+ packs.each { |pack| puts " #{format_installed_pack(pack)}" }
38
+ end
39
+
40
+ def list_available(ctx)
41
+ ctx.renderer.info('Fetching available packs from registry...')
42
+ result = RubynCode::Skills::RegistryClient.new.fetch_catalog
43
+ packs = result[:data]
44
+ return ctx.renderer.info('No packs found in the registry.') unless valid_results?(packs)
45
+
46
+ pack_manager = RubynCode::Skills::PackManager.new
47
+ ctx.renderer.info("Available skill packs (#{packs.size}):")
48
+ packs.each { |pack| puts " #{format_available_pack(pack, pack_manager)}" }
49
+ end
50
+
51
+ def search_registry(term, ctx)
52
+ if term.nil? || term.strip.empty?
53
+ ctx.renderer.warning('Usage: /skills search <term>')
54
+ return
55
+ end
56
+
57
+ query = term.strip
58
+ ctx.renderer.info("Searching registry for '#{query}'...")
59
+ result = RubynCode::Skills::RegistryClient.new.search_packs(query)
60
+ packs = result[:data]
61
+
62
+ unless valid_results?(packs)
63
+ ctx.renderer.info("No packs found matching '#{query}'.")
64
+ return
65
+ end
66
+
67
+ ctx.renderer.info("Packs matching '#{query}' (#{packs.size}):")
68
+ packs.each { |pack| puts " #{format_pack_line(pack)}" }
69
+ end
70
+
71
+ def valid_results?(results)
72
+ results.is_a?(Array) && !results.empty?
73
+ end
74
+
75
+ def format_installed_pack(pack)
76
+ version = pack[:version] ? " v#{pack[:version]}" : ''
77
+ desc = pack[:description].to_s.empty? ? '' : " — #{pack[:description]}"
78
+ "#{pack[:name]}#{version}#{desc}"
79
+ end
80
+
81
+ def format_available_pack(pack, pack_manager)
82
+ name = pack_name(pack)
83
+ installed = pack_manager.installed?(name) ? ' [installed]' : ''
84
+ "#{format_pack_line(pack)}#{installed}"
85
+ end
86
+
87
+ def format_pack_line(pack)
88
+ name = pack_name(pack)
89
+ desc = pack_description(pack)
90
+ label = desc.empty? ? '' : " — #{desc}"
91
+ "#{name}#{label}"
92
+ end
93
+
94
+ def pack_name(pack)
95
+ pack[:name] || pack['name']
96
+ end
97
+
98
+ def pack_description(pack)
99
+ (pack[:description] || pack['description']).to_s
100
+ end
101
+ end
102
+ end
103
+ end
104
+ end
@@ -29,6 +29,8 @@ module RubynCode
29
29
  @renderer.error("Daemon failed: #{e.message}")
30
30
  RubynCode::Debug.warn(e.backtrace&.first(5)&.join("\n")) if @options[:debug]
31
31
  exit(1)
32
+ ensure
33
+ disconnect_mcp_clients!
32
34
  end
33
35
 
34
36
  private
@@ -37,6 +39,7 @@ module RubynCode
37
39
  ensure_home_dir!
38
40
  ensure_auth!
39
41
  setup_database!
42
+ setup_mcp_servers!
40
43
  display_banner!
41
44
  end
42
45
 
@@ -141,6 +144,39 @@ module RubynCode
141
144
  @renderer.info(" Tasks completed: #{status[:runs_completed]}")
142
145
  @renderer.info(format(' Total cost: $%.4f', status[:total_cost]))
143
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
179
+ end
144
180
  end
145
181
  end
146
182
  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
@@ -34,6 +34,7 @@ module RubynCode
34
34
 
35
35
  @renderer.welcome
36
36
  @version_check.notify
37
+ check_skill_suggestions!
37
38
 
38
39
  at_exit { shutdown! }
39
40
 
@@ -44,6 +45,14 @@ module RubynCode
44
45
 
45
46
  private
46
47
 
48
+ def check_skill_suggestions!
49
+ suggest = Skills::AutoSuggest.new(project_root: @project_root)
50
+ message = suggest.check
51
+ @renderer.info(message) if message
52
+ rescue StandardError
53
+ # Never block session start on suggestion failure
54
+ end
55
+
47
56
  def run_input_loop
48
57
  while @running
49
58
  begin
@@ -98,6 +107,7 @@ module RubynCode
98
107
  @stream_formatter&.feed(text)
99
108
  end
100
109
 
110
+ # -- sequential steps with interrupt rescue
101
111
  def handle_message(input)
102
112
  @spinner.start
103
113
  @streaming_first_chunk = true
@@ -113,6 +123,11 @@ module RubynCode
113
123
  puts
114
124
  end
115
125
 
126
+ save_session!
127
+ rescue Interrupt
128
+ @spinner.stop
129
+ puts
130
+ @renderer.warning('Interrupted — session state preserved')
116
131
  save_session!
117
132
  rescue BudgetExceededError => e
118
133
  @spinner.error
@@ -21,7 +21,9 @@ module RubynCode
21
21
  Commands::Version, Commands::Review, Commands::Resume,
22
22
  Commands::Spawn, Commands::Doctor, Commands::Tokens,
23
23
  Commands::Plan, Commands::ContextInfo, Commands::Diff,
24
- Commands::Model, Commands::NewSession
24
+ Commands::Model, Commands::NewSession, Commands::Mcp,
25
+ Commands::Provider, Commands::InstallSkills,
26
+ Commands::RemoveSkills, Commands::Skills
25
27
  ].each { |cmd| @command_registry.register(cmd) }
26
28
  end
27
29
 
@@ -48,6 +48,7 @@ module RubynCode
48
48
  @renderer.info('Saving session...')
49
49
  save_session!
50
50
  @background_worker&.shutdown!
51
+ disconnect_mcp_clients!
51
52
  extract_learnings_if_needed
52
53
  decay_instincts
53
54
  @renderer.info("Session saved. Rubyn out. \u270C\uFE0F")