rubyn-code 0.1.0 → 0.2.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 (159) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +269 -467
  3. data/db/migrations/009_create_teams.sql +6 -6
  4. data/db/migrations/011_fix_mailbox_messages_columns.rb +35 -0
  5. data/db/migrations/012_expand_mailbox_message_types.rb +37 -0
  6. data/exe/rubyn-code +1 -1
  7. data/lib/rubyn_code/agent/RUBYN.md +17 -0
  8. data/lib/rubyn_code/agent/conversation.rb +68 -19
  9. data/lib/rubyn_code/agent/loop.rb +312 -54
  10. data/lib/rubyn_code/agent/loop_detector.rb +6 -6
  11. data/lib/rubyn_code/auth/RUBYN.md +19 -0
  12. data/lib/rubyn_code/auth/oauth.rb +40 -35
  13. data/lib/rubyn_code/auth/server.rb +16 -12
  14. data/lib/rubyn_code/auth/token_store.rb +22 -22
  15. data/lib/rubyn_code/autonomous/RUBYN.md +14 -0
  16. data/lib/rubyn_code/autonomous/daemon.rb +115 -79
  17. data/lib/rubyn_code/autonomous/idle_poller.rb +4 -8
  18. data/lib/rubyn_code/autonomous/task_claimer.rb +11 -11
  19. data/lib/rubyn_code/background/RUBYN.md +13 -0
  20. data/lib/rubyn_code/background/notifier.rb +0 -2
  21. data/lib/rubyn_code/background/worker.rb +60 -15
  22. data/lib/rubyn_code/cli/RUBYN.md +30 -0
  23. data/lib/rubyn_code/cli/app.rb +85 -9
  24. data/lib/rubyn_code/cli/commands/RUBYN.md +133 -0
  25. data/lib/rubyn_code/cli/commands/base.rb +53 -0
  26. data/lib/rubyn_code/cli/commands/budget.rb +24 -0
  27. data/lib/rubyn_code/cli/commands/clear.rb +16 -0
  28. data/lib/rubyn_code/cli/commands/compact.rb +21 -0
  29. data/lib/rubyn_code/cli/commands/context.rb +44 -0
  30. data/lib/rubyn_code/cli/commands/context_info.rb +56 -0
  31. data/lib/rubyn_code/cli/commands/cost.rb +23 -0
  32. data/lib/rubyn_code/cli/commands/diff.rb +30 -0
  33. data/lib/rubyn_code/cli/commands/doctor.rb +112 -0
  34. data/lib/rubyn_code/cli/commands/help.rb +41 -0
  35. data/lib/rubyn_code/cli/commands/model.rb +37 -0
  36. data/lib/rubyn_code/cli/commands/plan.rb +22 -0
  37. data/lib/rubyn_code/cli/commands/quit.rb +17 -0
  38. data/lib/rubyn_code/cli/commands/registry.rb +64 -0
  39. data/lib/rubyn_code/cli/commands/resume.rb +51 -0
  40. data/lib/rubyn_code/cli/commands/review.rb +26 -0
  41. data/lib/rubyn_code/cli/commands/skill.rb +32 -0
  42. data/lib/rubyn_code/cli/commands/spawn.rb +24 -0
  43. data/lib/rubyn_code/cli/commands/tasks.rb +32 -0
  44. data/lib/rubyn_code/cli/commands/tokens.rb +76 -0
  45. data/lib/rubyn_code/cli/commands/undo.rb +17 -0
  46. data/lib/rubyn_code/cli/commands/version.rb +16 -0
  47. data/lib/rubyn_code/cli/daemon_runner.rb +129 -0
  48. data/lib/rubyn_code/cli/input_handler.rb +20 -23
  49. data/lib/rubyn_code/cli/renderer.rb +25 -27
  50. data/lib/rubyn_code/cli/repl.rb +161 -194
  51. data/lib/rubyn_code/cli/setup.rb +117 -0
  52. data/lib/rubyn_code/cli/spinner.rb +40 -40
  53. data/lib/rubyn_code/cli/stream_formatter.rb +29 -28
  54. data/lib/rubyn_code/cli/version_check.rb +94 -0
  55. data/lib/rubyn_code/config/RUBYN.md +14 -0
  56. data/lib/rubyn_code/config/defaults.rb +28 -19
  57. data/lib/rubyn_code/config/project_config.rb +7 -9
  58. data/lib/rubyn_code/config/settings.rb +3 -3
  59. data/lib/rubyn_code/context/RUBYN.md +20 -0
  60. data/lib/rubyn_code/context/auto_compact.rb +7 -7
  61. data/lib/rubyn_code/context/compactor.rb +2 -2
  62. data/lib/rubyn_code/context/context_collapse.rb +45 -0
  63. data/lib/rubyn_code/context/manager.rb +20 -3
  64. data/lib/rubyn_code/context/manual_compact.rb +7 -7
  65. data/lib/rubyn_code/context/micro_compact.rb +12 -12
  66. data/lib/rubyn_code/db/RUBYN.md +40 -0
  67. data/lib/rubyn_code/db/connection.rb +13 -13
  68. data/lib/rubyn_code/db/migrator.rb +67 -27
  69. data/lib/rubyn_code/db/schema.rb +6 -6
  70. data/lib/rubyn_code/debug.rb +74 -0
  71. data/lib/rubyn_code/hooks/RUBYN.md +17 -0
  72. data/lib/rubyn_code/hooks/built_in.rb +9 -9
  73. data/lib/rubyn_code/hooks/registry.rb +5 -5
  74. data/lib/rubyn_code/hooks/runner.rb +1 -1
  75. data/lib/rubyn_code/hooks/user_hooks.rb +16 -16
  76. data/lib/rubyn_code/learning/RUBYN.md +16 -0
  77. data/lib/rubyn_code/learning/extractor.rb +22 -22
  78. data/lib/rubyn_code/learning/injector.rb +17 -18
  79. data/lib/rubyn_code/learning/instinct.rb +18 -14
  80. data/lib/rubyn_code/llm/RUBYN.md +15 -0
  81. data/lib/rubyn_code/llm/client.rb +121 -55
  82. data/lib/rubyn_code/llm/message_builder.rb +19 -15
  83. data/lib/rubyn_code/llm/streaming.rb +80 -50
  84. data/lib/rubyn_code/mcp/RUBYN.md +21 -0
  85. data/lib/rubyn_code/mcp/client.rb +25 -24
  86. data/lib/rubyn_code/mcp/config.rb +7 -7
  87. data/lib/rubyn_code/mcp/sse_transport.rb +27 -26
  88. data/lib/rubyn_code/mcp/stdio_transport.rb +22 -19
  89. data/lib/rubyn_code/mcp/tool_bridge.rb +32 -32
  90. data/lib/rubyn_code/memory/RUBYN.md +17 -0
  91. data/lib/rubyn_code/memory/models.rb +3 -3
  92. data/lib/rubyn_code/memory/search.rb +17 -17
  93. data/lib/rubyn_code/memory/session_persistence.rb +49 -34
  94. data/lib/rubyn_code/memory/store.rb +17 -17
  95. data/lib/rubyn_code/observability/RUBYN.md +19 -0
  96. data/lib/rubyn_code/observability/budget_enforcer.rb +16 -15
  97. data/lib/rubyn_code/observability/cost_calculator.rb +3 -3
  98. data/lib/rubyn_code/observability/token_counter.rb +1 -1
  99. data/lib/rubyn_code/observability/usage_reporter.rb +35 -35
  100. data/lib/rubyn_code/output/RUBYN.md +11 -0
  101. data/lib/rubyn_code/output/diff_renderer.rb +6 -6
  102. data/lib/rubyn_code/output/formatter.rb +4 -4
  103. data/lib/rubyn_code/permissions/RUBYN.md +17 -0
  104. data/lib/rubyn_code/permissions/prompter.rb +8 -8
  105. data/lib/rubyn_code/protocols/RUBYN.md +14 -0
  106. data/lib/rubyn_code/protocols/interrupt_handler.rb +1 -1
  107. data/lib/rubyn_code/protocols/plan_approval.rb +9 -9
  108. data/lib/rubyn_code/protocols/shutdown_handshake.rb +9 -11
  109. data/lib/rubyn_code/skills/RUBYN.md +19 -0
  110. data/lib/rubyn_code/skills/catalog.rb +7 -7
  111. data/lib/rubyn_code/skills/document.rb +15 -15
  112. data/lib/rubyn_code/skills/loader.rb +6 -8
  113. data/lib/rubyn_code/sub_agents/RUBYN.md +12 -0
  114. data/lib/rubyn_code/sub_agents/runner.rb +15 -15
  115. data/lib/rubyn_code/sub_agents/summarizer.rb +1 -1
  116. data/lib/rubyn_code/tasks/RUBYN.md +13 -0
  117. data/lib/rubyn_code/tasks/dag.rb +12 -16
  118. data/lib/rubyn_code/tasks/manager.rb +24 -24
  119. data/lib/rubyn_code/tasks/models.rb +4 -4
  120. data/lib/rubyn_code/teams/RUBYN.md +14 -0
  121. data/lib/rubyn_code/teams/mailbox.rb +38 -18
  122. data/lib/rubyn_code/teams/manager.rb +19 -19
  123. data/lib/rubyn_code/teams/teammate.rb +3 -4
  124. data/lib/rubyn_code/tools/RUBYN.md +38 -0
  125. data/lib/rubyn_code/tools/background_run.rb +9 -11
  126. data/lib/rubyn_code/tools/base.rb +54 -3
  127. data/lib/rubyn_code/tools/bash.rb +16 -34
  128. data/lib/rubyn_code/tools/bundle_add.rb +10 -12
  129. data/lib/rubyn_code/tools/bundle_install.rb +9 -11
  130. data/lib/rubyn_code/tools/compact.rb +10 -9
  131. data/lib/rubyn_code/tools/db_migrate.rb +17 -15
  132. data/lib/rubyn_code/tools/edit_file.rb +12 -12
  133. data/lib/rubyn_code/tools/executor.rb +9 -4
  134. data/lib/rubyn_code/tools/git_commit.rb +29 -34
  135. data/lib/rubyn_code/tools/git_diff.rb +17 -18
  136. data/lib/rubyn_code/tools/git_log.rb +17 -19
  137. data/lib/rubyn_code/tools/git_status.rb +18 -20
  138. data/lib/rubyn_code/tools/glob.rb +7 -9
  139. data/lib/rubyn_code/tools/grep.rb +11 -9
  140. data/lib/rubyn_code/tools/load_skill.rb +7 -7
  141. data/lib/rubyn_code/tools/memory_search.rb +13 -12
  142. data/lib/rubyn_code/tools/memory_write.rb +14 -12
  143. data/lib/rubyn_code/tools/rails_generate.rb +16 -16
  144. data/lib/rubyn_code/tools/read_file.rb +8 -7
  145. data/lib/rubyn_code/tools/read_inbox.rb +5 -5
  146. data/lib/rubyn_code/tools/registry.rb +2 -2
  147. data/lib/rubyn_code/tools/review_pr.rb +55 -55
  148. data/lib/rubyn_code/tools/run_specs.rb +20 -19
  149. data/lib/rubyn_code/tools/schema.rb +9 -11
  150. data/lib/rubyn_code/tools/send_message.rb +10 -10
  151. data/lib/rubyn_code/tools/spawn_agent.rb +51 -23
  152. data/lib/rubyn_code/tools/spawn_teammate.rb +21 -21
  153. data/lib/rubyn_code/tools/task.rb +28 -28
  154. data/lib/rubyn_code/tools/web_fetch.rb +46 -31
  155. data/lib/rubyn_code/tools/web_search.rb +64 -66
  156. data/lib/rubyn_code/tools/write_file.rb +7 -6
  157. data/lib/rubyn_code/version.rb +1 -1
  158. data/lib/rubyn_code.rb +136 -105
  159. metadata +94 -21
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubynCode
4
+ module CLI
5
+ module Commands
6
+ class ContextInfo < Base
7
+ def self.command_name = '/context'
8
+ def self.description = 'Show context window usage'
9
+
10
+ CONTEXT_WINDOW = 200_000
11
+
12
+ def execute(_args, ctx)
13
+ stats = estimate_context(ctx)
14
+ render_context_bar(stats, ctx)
15
+ end
16
+
17
+ private
18
+
19
+ def estimate_context(ctx)
20
+ messages = ctx.conversation.messages
21
+ estimated = Observability::TokenCounter.estimate_messages(messages)
22
+ pct = ((estimated.to_f / CONTEXT_WINDOW) * 100).round(1)
23
+ { estimated: estimated, pct: pct, message_count: messages.size }
24
+ end
25
+
26
+ def render_context_bar(stats, ctx)
27
+ puts
28
+ puts " Context: [#{progress_bar(stats[:pct])}] #{stats[:pct]}%"
29
+ puts " #{fmt(stats[:estimated])} / #{fmt(CONTEXT_WINDOW)} tokens • #{stats[:message_count]} messages"
30
+ puts " Model: #{Config::Defaults::DEFAULT_MODEL}#{plan_label(ctx)}"
31
+ puts
32
+ end
33
+
34
+ def progress_bar(pct, width: 30)
35
+ filled = [(pct / 100.0 * width).round, width].min
36
+ "#{bar_color(pct)}#{'█' * filled}\e[0m#{'░' * (width - filled)}"
37
+ end
38
+
39
+ def bar_color(pct)
40
+ return "\e[31m" if pct >= 80
41
+ return "\e[33m" if pct >= 50
42
+
43
+ "\e[32m"
44
+ end
45
+
46
+ def plan_label(ctx)
47
+ ctx.plan_mode? ? ' • 🧠 plan mode' : ''
48
+ end
49
+
50
+ def fmt(num)
51
+ num.to_s.reverse.gsub(/(\d{3})(?=\d)/, '\1,').reverse
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubynCode
4
+ module CLI
5
+ module Commands
6
+ class Cost < Base
7
+ def self.command_name = '/cost'
8
+ def self.description = 'Show token usage and costs'
9
+
10
+ def execute(_args, ctx)
11
+ ctx.renderer.cost_summary(
12
+ session_cost: ctx.budget_enforcer.session_cost,
13
+ daily_cost: ctx.budget_enforcer.daily_cost,
14
+ tokens: {
15
+ input: ctx.context_manager.total_input_tokens,
16
+ output: ctx.context_manager.total_output_tokens
17
+ }
18
+ )
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubynCode
4
+ module CLI
5
+ module Commands
6
+ class Diff < Base
7
+ def self.command_name = '/diff'
8
+ def self.description = 'Show git diff (staged, unstaged, or vs branch)'
9
+
10
+ def execute(args, ctx)
11
+ target = args.first || 'unstaged'
12
+
13
+ cmd = case target
14
+ when 'staged' then 'git diff --cached'
15
+ when 'unstaged' then 'git diff'
16
+ else "git diff #{target}...HEAD"
17
+ end
18
+
19
+ output = `cd #{ctx.project_root} && #{cmd} 2>&1`
20
+
21
+ if output.strip.empty?
22
+ ctx.renderer.info("No changes (#{target}).")
23
+ else
24
+ puts output
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubynCode
4
+ module CLI
5
+ module Commands
6
+ class Doctor < Base
7
+ def self.command_name = '/doctor'
8
+ def self.description = 'Environment health check'
9
+
10
+ CHECKS = %i[
11
+ check_ruby
12
+ check_bundler
13
+ check_database
14
+ check_auth
15
+ check_skills
16
+ check_project
17
+ ].freeze
18
+
19
+ def execute(_args, ctx)
20
+ ctx.renderer.info('Rubyn Code Doctor 🩺')
21
+ puts
22
+
23
+ results = CHECKS.map { |check| send(check, ctx) }
24
+ results.each { |label, ok, detail| render_check(label, ok, detail) }
25
+
26
+ puts
27
+ render_summary(results, ctx.renderer)
28
+ end
29
+
30
+ private
31
+
32
+ def render_check(label, passed, detail)
33
+ icon = passed ? green('✓') : red('✗')
34
+ suffix = detail ? " — #{detail}" : ''
35
+ puts " #{icon} #{label}#{suffix}"
36
+ end
37
+
38
+ def render_summary(results, renderer)
39
+ passed = results.count { |_, success, _| success }
40
+ failed = results.size - passed
41
+ summary = "#{passed} passed, #{failed} failed"
42
+
43
+ if failed.zero?
44
+ renderer.success("All checks passed! #{summary}")
45
+ else
46
+ renderer.warning("#{summary}. Fix the issues above.")
47
+ end
48
+ end
49
+
50
+ def check_ruby(_ctx)
51
+ version = RUBY_VERSION
52
+ ok = Gem::Version.new(version) >= Gem::Version.new('3.2')
53
+ ['Ruby version', ok, "#{version} (#{RUBY_PLATFORM})"]
54
+ end
55
+
56
+ def check_bundler(_ctx)
57
+ version = Gem.loaded_specs['bundler']&.version&.to_s || Bundler::VERSION
58
+ ['Bundler', true, "v#{version}"]
59
+ rescue StandardError
60
+ ['Bundler', false, 'not found']
61
+ end
62
+
63
+ def check_database(ctx)
64
+ db = ctx.db
65
+ count = db.query('SELECT COUNT(*) AS c FROM schema_migrations').first
66
+ migrations = count['c'] || count[:c]
67
+ ['Database', true, "#{migrations} migrations applied"]
68
+ rescue StandardError => e
69
+ ['Database', false, e.message]
70
+ end
71
+
72
+ def check_auth(_ctx)
73
+ if Auth::TokenStore.valid?
74
+ tokens = Auth::TokenStore.load
75
+ source = tokens&.fetch(:source, :unknown)
76
+ ['Authentication', true, source.to_s]
77
+ else
78
+ ['Authentication', false, 'no valid token found']
79
+ end
80
+ rescue StandardError => e
81
+ ['Authentication', false, e.message]
82
+ end
83
+
84
+ def check_skills(ctx)
85
+ catalog = ctx.skill_loader.catalog
86
+ count = catalog.list.size
87
+ ['Skills', count.positive?, "#{count} skills available"]
88
+ rescue StandardError => e
89
+ ['Skills', false, e.message]
90
+ end
91
+
92
+ def check_project(ctx)
93
+ gemfile = File.join(ctx.project_root, 'Gemfile')
94
+ return ['Project detected', false, 'no Gemfile found'] unless File.exist?(gemfile)
95
+
96
+ type = detect_project_type(ctx.project_root)
97
+ ['Project detected', true, "#{type} at #{ctx.project_root}"]
98
+ end
99
+
100
+ def detect_project_type(root)
101
+ return 'Rails' if File.exist?(File.join(root, 'config', 'application.rb'))
102
+ return 'Ruby' if File.exist?(File.join(root, 'Rakefile'))
103
+
104
+ 'Ruby (Gemfile)'
105
+ end
106
+
107
+ def green(text) = "\e[32m#{text}\e[0m"
108
+ def red(text) = "\e[31m#{text}\e[0m"
109
+ end
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubynCode
4
+ module CLI
5
+ module Commands
6
+ class Help < Base
7
+ def self.command_name = '/help'
8
+ def self.description = 'Show this help message'
9
+
10
+ def execute(_args, ctx)
11
+ ctx.renderer.info('Available commands:')
12
+ puts
13
+ render_commands(self.class.registry)
14
+ render_tips
15
+ end
16
+
17
+ private
18
+
19
+ def render_commands(registry)
20
+ registry.visible_commands.each do |cmd_class|
21
+ names = cmd_class.all_names.join(', ')
22
+ puts " #{names.ljust(25)} #{cmd_class.description}"
23
+ end
24
+ end
25
+
26
+ def render_tips
27
+ puts
28
+ puts ' Tips:'
29
+ puts ' - Use @filename to include file contents in your message'
30
+ puts ' - End a line with \ for multiline input'
31
+ puts ' - Type / to list all commands'
32
+ puts
33
+ end
34
+
35
+ class << self
36
+ attr_accessor :registry
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubynCode
4
+ module CLI
5
+ module Commands
6
+ class Model < Base
7
+ def self.command_name = '/model'
8
+ def self.description = 'Show or switch model (/model [name])'
9
+
10
+ KNOWN_MODELS = %w[
11
+ claude-haiku-4-5
12
+ claude-sonnet-4-20250514
13
+ claude-opus-4-20250514
14
+ ].freeze
15
+
16
+ def execute(args, ctx)
17
+ name = args.first
18
+
19
+ if name
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
25
+
26
+ ctx.renderer.info("Model switched to #{name}")
27
+ { action: :set_model, model: name }
28
+ else
29
+ current = Config::Defaults::DEFAULT_MODEL
30
+ ctx.renderer.info("Current model: #{current}")
31
+ ctx.renderer.info("Available: #{KNOWN_MODELS.join(', ')}")
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubynCode
4
+ module CLI
5
+ module Commands
6
+ class Plan < Base
7
+ def self.command_name = '/plan'
8
+ def self.description = 'Toggle plan mode (think before acting)'
9
+
10
+ def execute(_args, ctx)
11
+ if ctx.plan_mode?
12
+ ctx.renderer.info('Plan mode OFF — back to full execution. 🚀')
13
+ { action: :set_plan_mode, enabled: false }
14
+ else
15
+ ctx.renderer.info("Plan mode ON — read-only tools only. I can explore but won't change anything. 🧠")
16
+ { action: :set_plan_mode, enabled: true }
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubynCode
4
+ module CLI
5
+ module Commands
6
+ class Quit < Base
7
+ def self.command_name = '/quit'
8
+ def self.description = 'Exit Rubyn Code'
9
+ def self.aliases = ['/exit', '/q'].freeze
10
+
11
+ def execute(_args, _ctx)
12
+ :quit
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubynCode
4
+ module CLI
5
+ module Commands
6
+ # Discovers, registers, and dispatches slash commands.
7
+ #
8
+ # Commands are registered by class reference. The registry builds
9
+ # a lookup table from command names + aliases → command class.
10
+ class Registry
11
+ def initialize
12
+ @commands = {} # '/name' => CommandClass
13
+ @classes = []
14
+ end
15
+
16
+ # Register a command class.
17
+ #
18
+ # @param command_class [Class<Commands::Base>]
19
+ # @return [void]
20
+ def register(command_class)
21
+ @classes << command_class
22
+ command_class.all_names.each do |name|
23
+ @commands[name] = command_class
24
+ end
25
+ end
26
+
27
+ # Look up and execute a command by name.
28
+ #
29
+ # @param name [String] the slash command (e.g. '/doctor')
30
+ # @param args [Array<String>] arguments
31
+ # @param ctx [Commands::Context] shared context
32
+ # @return [Symbol, nil] :quit if the command signals exit, nil otherwise
33
+ def dispatch(name, args, ctx)
34
+ command_class = @commands[name]
35
+ return :unknown unless command_class
36
+
37
+ command_class.new.execute(args, ctx)
38
+ end
39
+
40
+ # All registered command names (for tab completion).
41
+ #
42
+ # @return [Array<String>]
43
+ def completions
44
+ @commands.keys.sort.freeze
45
+ end
46
+
47
+ # Visible commands for /help (excludes hidden commands).
48
+ #
49
+ # @return [Array<Class<Commands::Base>>] unique, sorted by name
50
+ def visible_commands
51
+ @classes
52
+ .reject(&:hidden?)
53
+ .sort_by(&:command_name)
54
+ end
55
+
56
+ # @param name [String]
57
+ # @return [Boolean]
58
+ def known?(name)
59
+ @commands.key?(name)
60
+ end
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubynCode
4
+ module CLI
5
+ module Commands
6
+ class Resume < Base
7
+ def self.command_name = '/resume'
8
+ def self.description = 'Resume a session or list recent sessions'
9
+
10
+ def execute(args, ctx)
11
+ session_id = args.first
12
+
13
+ if session_id
14
+ resume_session(session_id, ctx)
15
+ else
16
+ list_sessions(ctx)
17
+ end
18
+ end
19
+
20
+ private
21
+
22
+ def resume_session(session_id, ctx)
23
+ data = ctx.session_persistence.load_session(session_id)
24
+
25
+ if data
26
+ ctx.conversation.replace!(data[:messages])
27
+ ctx.renderer.info("Resumed session #{session_id[0..7]}")
28
+ { action: :set_session_id, session_id: session_id }
29
+ else
30
+ ctx.renderer.error("Session not found: #{session_id}")
31
+ end
32
+ end
33
+
34
+ def list_sessions(ctx)
35
+ sessions = ctx.session_persistence.list_sessions(
36
+ project_path: ctx.project_root,
37
+ limit: 10
38
+ )
39
+
40
+ if sessions.empty?
41
+ ctx.renderer.info('No previous sessions.')
42
+ else
43
+ sessions.each do |s|
44
+ puts " #{s[:id][0..7]} | #{s[:title] || 'untitled'} | #{s[:created_at]}"
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubynCode
4
+ module CLI
5
+ module Commands
6
+ class Review < Base
7
+ def self.command_name = '/review'
8
+ def self.description = 'Review current branch against best practices'
9
+
10
+ def execute(args, ctx)
11
+ base = args.fetch(0, 'main')
12
+ focus = args.fetch(1, 'all')
13
+
14
+ ctx.send_message(build_prompt(base, focus))
15
+ end
16
+
17
+ private
18
+
19
+ def build_prompt(base, focus)
20
+ "Use the review_pr tool to review my current branch against #{base}. " \
21
+ "Focus: #{focus}. Load relevant best practice skills for any issues you find."
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubynCode
4
+ module CLI
5
+ module Commands
6
+ class Skill < Base
7
+ def self.command_name = '/skill'
8
+ def self.description = 'Load a skill or list available skills'
9
+
10
+ def execute(args, ctx)
11
+ args.first ? load_skill(args.first, ctx) : list_skills(ctx)
12
+ rescue StandardError => e
13
+ ctx.renderer.error("Skill error: #{e.message}")
14
+ end
15
+
16
+ private
17
+
18
+ def load_skill(name, ctx)
19
+ content = ctx.skill_loader.load(name)
20
+ ctx.renderer.info("Loaded skill: #{name}")
21
+ ctx.conversation.add_user_message("<skill>#{content}</skill>")
22
+ end
23
+
24
+ def list_skills(ctx)
25
+ skills = ctx.skill_loader.catalog.list
26
+ ctx.renderer.info("Available skills (#{skills.size}):")
27
+ skills.each { |skill| puts " /#{skill}" }
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubynCode
4
+ module CLI
5
+ module Commands
6
+ class Spawn < Base
7
+ def self.command_name = '/spawn'
8
+ def self.description = 'Spawn a teammate agent (/spawn <name> [role])'
9
+
10
+ def execute(args, ctx)
11
+ name = args[0]
12
+ unless name
13
+ ctx.renderer.error('Usage: /spawn <name> [role]')
14
+ return
15
+ end
16
+
17
+ role = args[1] || 'coder'
18
+
19
+ { action: :spawn_teammate, name: name, role: role }
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubynCode
4
+ module CLI
5
+ module Commands
6
+ class Tasks < Base
7
+ def self.command_name = '/tasks'
8
+ def self.description = 'List all tasks'
9
+
10
+ STATUS_COLORS = {
11
+ 'completed' => :green,
12
+ 'in_progress' => :yellow,
13
+ 'blocked' => :red
14
+ }.freeze
15
+
16
+ def execute(_args, ctx)
17
+ task_manager = ::RubynCode::Tasks::Manager.new(ctx.db)
18
+ tasks = task_manager.list
19
+
20
+ if tasks.empty?
21
+ ctx.renderer.info('No tasks.')
22
+ return
23
+ end
24
+
25
+ tasks.each do |t|
26
+ puts " [#{t[:status]}] #{t[:title]} (#{t[:id][0..7]})"
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubynCode
4
+ module CLI
5
+ module Commands
6
+ class Tokens < Base
7
+ def self.command_name = '/tokens'
8
+ def self.description = 'Show token usage and context window estimate'
9
+
10
+ # Claude's context window
11
+ CONTEXT_WINDOW = 200_000
12
+
13
+ TokenStats = Data.define(:estimated, :threshold, :actual_in, :actual_out, :message_count) do
14
+ def pct_window = ((estimated.to_f / CONTEXT_WINDOW) * 100).round(1)
15
+ def pct_threshold = ((estimated.to_f / threshold) * 100).round(1)
16
+ def total = actual_in + actual_out
17
+ end
18
+
19
+ def execute(_args, ctx)
20
+ stats = gather_stats(ctx)
21
+ render_estimation(stats)
22
+ render_actual_usage(stats)
23
+ warn_if_near_threshold(stats, ctx.renderer)
24
+ end
25
+
26
+ private
27
+
28
+ def gather_stats(ctx)
29
+ mgr = ctx.context_manager
30
+ estimated = Observability::TokenCounter.estimate_messages(ctx.conversation.messages)
31
+ threshold = mgr.instance_variable_get(:@threshold) || 50_000
32
+
33
+ TokenStats.new(
34
+ estimated: estimated, threshold: threshold,
35
+ actual_in: mgr.total_input_tokens,
36
+ actual_out: mgr.total_output_tokens,
37
+ message_count: ctx.conversation.messages.size
38
+ )
39
+ end
40
+
41
+ def render_estimation(stats)
42
+ puts
43
+ puts " #{bold('Token Estimation')}"
44
+ puts " #{dim('─' * 40)}"
45
+ puts " Context estimate: #{fmt(stats.estimated)} tokens " \
46
+ "(~#{stats.pct_window}% of #{fmt(CONTEXT_WINDOW)} window)"
47
+ puts " Compaction at: #{fmt(stats.threshold)} tokens (#{stats.pct_threshold}% used)"
48
+ puts " Messages: #{stats.message_count}"
49
+ end
50
+
51
+ def render_actual_usage(stats)
52
+ puts
53
+ puts " #{bold('Actual Usage (this session)')}"
54
+ puts " #{dim('─' * 40)}"
55
+ puts " Input tokens: #{fmt(stats.actual_in)}"
56
+ puts " Output tokens: #{fmt(stats.actual_out)}"
57
+ puts " Total: #{fmt(stats.total)}"
58
+ puts
59
+ end
60
+
61
+ def warn_if_near_threshold(stats, renderer)
62
+ return unless stats.pct_threshold >= 80
63
+
64
+ renderer.warning('⚠ Context nearing compaction threshold. Consider /compact.')
65
+ end
66
+
67
+ def bold(text) = "\e[1m#{text}\e[0m"
68
+ def dim(text) = "\e[2m#{text}\e[0m"
69
+
70
+ def fmt(num)
71
+ num.to_s.reverse.gsub(/(\d{3})(?=\d)/, '\1,').reverse
72
+ end
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubynCode
4
+ module CLI
5
+ module Commands
6
+ class Undo < Base
7
+ def self.command_name = '/undo'
8
+ def self.description = 'Remove last exchange'
9
+
10
+ def execute(_args, ctx)
11
+ ctx.conversation.undo_last!
12
+ ctx.renderer.info('Last exchange removed.')
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubynCode
4
+ module CLI
5
+ module Commands
6
+ class Version < Base
7
+ def self.command_name = '/version'
8
+ def self.description = 'Show version info'
9
+
10
+ def execute(_args, ctx)
11
+ ctx.renderer.info("Rubyn Code v#{RubynCode::VERSION}")
12
+ end
13
+ end
14
+ end
15
+ end
16
+ end