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.
- checksums.yaml +4 -4
- data/README.md +269 -467
- data/db/migrations/009_create_teams.sql +6 -6
- data/db/migrations/011_fix_mailbox_messages_columns.rb +35 -0
- data/db/migrations/012_expand_mailbox_message_types.rb +37 -0
- data/exe/rubyn-code +1 -1
- data/lib/rubyn_code/agent/RUBYN.md +17 -0
- data/lib/rubyn_code/agent/conversation.rb +68 -19
- data/lib/rubyn_code/agent/loop.rb +312 -54
- data/lib/rubyn_code/agent/loop_detector.rb +6 -6
- data/lib/rubyn_code/auth/RUBYN.md +19 -0
- data/lib/rubyn_code/auth/oauth.rb +40 -35
- data/lib/rubyn_code/auth/server.rb +16 -12
- data/lib/rubyn_code/auth/token_store.rb +22 -22
- data/lib/rubyn_code/autonomous/RUBYN.md +14 -0
- data/lib/rubyn_code/autonomous/daemon.rb +115 -79
- data/lib/rubyn_code/autonomous/idle_poller.rb +4 -8
- data/lib/rubyn_code/autonomous/task_claimer.rb +11 -11
- data/lib/rubyn_code/background/RUBYN.md +13 -0
- data/lib/rubyn_code/background/notifier.rb +0 -2
- data/lib/rubyn_code/background/worker.rb +60 -15
- data/lib/rubyn_code/cli/RUBYN.md +30 -0
- data/lib/rubyn_code/cli/app.rb +85 -9
- data/lib/rubyn_code/cli/commands/RUBYN.md +133 -0
- data/lib/rubyn_code/cli/commands/base.rb +53 -0
- data/lib/rubyn_code/cli/commands/budget.rb +24 -0
- data/lib/rubyn_code/cli/commands/clear.rb +16 -0
- data/lib/rubyn_code/cli/commands/compact.rb +21 -0
- data/lib/rubyn_code/cli/commands/context.rb +44 -0
- data/lib/rubyn_code/cli/commands/context_info.rb +56 -0
- data/lib/rubyn_code/cli/commands/cost.rb +23 -0
- data/lib/rubyn_code/cli/commands/diff.rb +30 -0
- data/lib/rubyn_code/cli/commands/doctor.rb +112 -0
- data/lib/rubyn_code/cli/commands/help.rb +41 -0
- data/lib/rubyn_code/cli/commands/model.rb +37 -0
- data/lib/rubyn_code/cli/commands/plan.rb +22 -0
- data/lib/rubyn_code/cli/commands/quit.rb +17 -0
- data/lib/rubyn_code/cli/commands/registry.rb +64 -0
- data/lib/rubyn_code/cli/commands/resume.rb +51 -0
- data/lib/rubyn_code/cli/commands/review.rb +26 -0
- data/lib/rubyn_code/cli/commands/skill.rb +32 -0
- data/lib/rubyn_code/cli/commands/spawn.rb +24 -0
- data/lib/rubyn_code/cli/commands/tasks.rb +32 -0
- data/lib/rubyn_code/cli/commands/tokens.rb +76 -0
- data/lib/rubyn_code/cli/commands/undo.rb +17 -0
- data/lib/rubyn_code/cli/commands/version.rb +16 -0
- data/lib/rubyn_code/cli/daemon_runner.rb +129 -0
- data/lib/rubyn_code/cli/input_handler.rb +20 -23
- data/lib/rubyn_code/cli/renderer.rb +25 -27
- data/lib/rubyn_code/cli/repl.rb +161 -194
- data/lib/rubyn_code/cli/setup.rb +117 -0
- data/lib/rubyn_code/cli/spinner.rb +40 -40
- data/lib/rubyn_code/cli/stream_formatter.rb +29 -28
- data/lib/rubyn_code/cli/version_check.rb +94 -0
- data/lib/rubyn_code/config/RUBYN.md +14 -0
- data/lib/rubyn_code/config/defaults.rb +28 -19
- data/lib/rubyn_code/config/project_config.rb +7 -9
- data/lib/rubyn_code/config/settings.rb +3 -3
- data/lib/rubyn_code/context/RUBYN.md +20 -0
- data/lib/rubyn_code/context/auto_compact.rb +7 -7
- data/lib/rubyn_code/context/compactor.rb +2 -2
- data/lib/rubyn_code/context/context_collapse.rb +45 -0
- data/lib/rubyn_code/context/manager.rb +20 -3
- data/lib/rubyn_code/context/manual_compact.rb +7 -7
- data/lib/rubyn_code/context/micro_compact.rb +12 -12
- data/lib/rubyn_code/db/RUBYN.md +40 -0
- data/lib/rubyn_code/db/connection.rb +13 -13
- data/lib/rubyn_code/db/migrator.rb +67 -27
- data/lib/rubyn_code/db/schema.rb +6 -6
- data/lib/rubyn_code/debug.rb +74 -0
- data/lib/rubyn_code/hooks/RUBYN.md +17 -0
- data/lib/rubyn_code/hooks/built_in.rb +9 -9
- data/lib/rubyn_code/hooks/registry.rb +5 -5
- data/lib/rubyn_code/hooks/runner.rb +1 -1
- data/lib/rubyn_code/hooks/user_hooks.rb +16 -16
- data/lib/rubyn_code/learning/RUBYN.md +16 -0
- data/lib/rubyn_code/learning/extractor.rb +22 -22
- data/lib/rubyn_code/learning/injector.rb +17 -18
- data/lib/rubyn_code/learning/instinct.rb +18 -14
- data/lib/rubyn_code/llm/RUBYN.md +15 -0
- data/lib/rubyn_code/llm/client.rb +121 -55
- data/lib/rubyn_code/llm/message_builder.rb +19 -15
- data/lib/rubyn_code/llm/streaming.rb +80 -50
- data/lib/rubyn_code/mcp/RUBYN.md +21 -0
- data/lib/rubyn_code/mcp/client.rb +25 -24
- data/lib/rubyn_code/mcp/config.rb +7 -7
- data/lib/rubyn_code/mcp/sse_transport.rb +27 -26
- data/lib/rubyn_code/mcp/stdio_transport.rb +22 -19
- data/lib/rubyn_code/mcp/tool_bridge.rb +32 -32
- data/lib/rubyn_code/memory/RUBYN.md +17 -0
- data/lib/rubyn_code/memory/models.rb +3 -3
- data/lib/rubyn_code/memory/search.rb +17 -17
- data/lib/rubyn_code/memory/session_persistence.rb +49 -34
- data/lib/rubyn_code/memory/store.rb +17 -17
- data/lib/rubyn_code/observability/RUBYN.md +19 -0
- data/lib/rubyn_code/observability/budget_enforcer.rb +16 -15
- data/lib/rubyn_code/observability/cost_calculator.rb +3 -3
- data/lib/rubyn_code/observability/token_counter.rb +1 -1
- data/lib/rubyn_code/observability/usage_reporter.rb +35 -35
- data/lib/rubyn_code/output/RUBYN.md +11 -0
- data/lib/rubyn_code/output/diff_renderer.rb +6 -6
- data/lib/rubyn_code/output/formatter.rb +4 -4
- data/lib/rubyn_code/permissions/RUBYN.md +17 -0
- data/lib/rubyn_code/permissions/prompter.rb +8 -8
- data/lib/rubyn_code/protocols/RUBYN.md +14 -0
- data/lib/rubyn_code/protocols/interrupt_handler.rb +1 -1
- data/lib/rubyn_code/protocols/plan_approval.rb +9 -9
- data/lib/rubyn_code/protocols/shutdown_handshake.rb +9 -11
- data/lib/rubyn_code/skills/RUBYN.md +19 -0
- data/lib/rubyn_code/skills/catalog.rb +7 -7
- data/lib/rubyn_code/skills/document.rb +15 -15
- data/lib/rubyn_code/skills/loader.rb +6 -8
- data/lib/rubyn_code/sub_agents/RUBYN.md +12 -0
- data/lib/rubyn_code/sub_agents/runner.rb +15 -15
- data/lib/rubyn_code/sub_agents/summarizer.rb +1 -1
- data/lib/rubyn_code/tasks/RUBYN.md +13 -0
- data/lib/rubyn_code/tasks/dag.rb +12 -16
- data/lib/rubyn_code/tasks/manager.rb +24 -24
- data/lib/rubyn_code/tasks/models.rb +4 -4
- data/lib/rubyn_code/teams/RUBYN.md +14 -0
- data/lib/rubyn_code/teams/mailbox.rb +38 -18
- data/lib/rubyn_code/teams/manager.rb +19 -19
- data/lib/rubyn_code/teams/teammate.rb +3 -4
- data/lib/rubyn_code/tools/RUBYN.md +38 -0
- data/lib/rubyn_code/tools/background_run.rb +9 -11
- data/lib/rubyn_code/tools/base.rb +54 -3
- data/lib/rubyn_code/tools/bash.rb +16 -34
- data/lib/rubyn_code/tools/bundle_add.rb +10 -12
- data/lib/rubyn_code/tools/bundle_install.rb +9 -11
- data/lib/rubyn_code/tools/compact.rb +10 -9
- data/lib/rubyn_code/tools/db_migrate.rb +17 -15
- data/lib/rubyn_code/tools/edit_file.rb +12 -12
- data/lib/rubyn_code/tools/executor.rb +9 -4
- data/lib/rubyn_code/tools/git_commit.rb +29 -34
- data/lib/rubyn_code/tools/git_diff.rb +17 -18
- data/lib/rubyn_code/tools/git_log.rb +17 -19
- data/lib/rubyn_code/tools/git_status.rb +18 -20
- data/lib/rubyn_code/tools/glob.rb +7 -9
- data/lib/rubyn_code/tools/grep.rb +11 -9
- data/lib/rubyn_code/tools/load_skill.rb +7 -7
- data/lib/rubyn_code/tools/memory_search.rb +13 -12
- data/lib/rubyn_code/tools/memory_write.rb +14 -12
- data/lib/rubyn_code/tools/rails_generate.rb +16 -16
- data/lib/rubyn_code/tools/read_file.rb +8 -7
- data/lib/rubyn_code/tools/read_inbox.rb +5 -5
- data/lib/rubyn_code/tools/registry.rb +2 -2
- data/lib/rubyn_code/tools/review_pr.rb +55 -55
- data/lib/rubyn_code/tools/run_specs.rb +20 -19
- data/lib/rubyn_code/tools/schema.rb +9 -11
- data/lib/rubyn_code/tools/send_message.rb +10 -10
- data/lib/rubyn_code/tools/spawn_agent.rb +51 -23
- data/lib/rubyn_code/tools/spawn_teammate.rb +21 -21
- data/lib/rubyn_code/tools/task.rb +28 -28
- data/lib/rubyn_code/tools/web_fetch.rb +46 -31
- data/lib/rubyn_code/tools/web_search.rb +64 -66
- data/lib/rubyn_code/tools/write_file.rb +7 -6
- data/lib/rubyn_code/version.rb +1 -1
- data/lib/rubyn_code.rb +136 -105
- 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
|