rubyn-code 0.7.0 → 0.8.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 (62) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +17 -1
  3. data/lib/rubyn_code/agent/conversation.rb +11 -1
  4. data/lib/rubyn_code/agent/dynamic_tool_schema.rb +1 -1
  5. data/lib/rubyn_code/agent/llm_caller.rb +5 -1
  6. data/lib/rubyn_code/agent/loop.rb +57 -3
  7. data/lib/rubyn_code/agent/response_parser.rb +8 -0
  8. data/lib/rubyn_code/agent/system_prompt_builder.rb +3 -0
  9. data/lib/rubyn_code/agent/tool_processor.rb +10 -0
  10. data/lib/rubyn_code/autonomous/daemon.rb +1 -1
  11. data/lib/rubyn_code/cli/commands/context.rb +27 -0
  12. data/lib/rubyn_code/cli/commands/custom_command.rb +44 -2
  13. data/lib/rubyn_code/cli/commands/custom_loader.rb +36 -5
  14. data/lib/rubyn_code/cli/commands/effort.rb +47 -0
  15. data/lib/rubyn_code/cli/commands/export.rb +174 -0
  16. data/lib/rubyn_code/cli/commands/mcp.rb +32 -8
  17. data/lib/rubyn_code/cli/commands/resume.rb +97 -26
  18. data/lib/rubyn_code/cli/commands/think.rb +47 -0
  19. data/lib/rubyn_code/cli/first_run.rb +1 -1
  20. data/lib/rubyn_code/cli/mention_expander.rb +19 -0
  21. data/lib/rubyn_code/cli/repl.rb +31 -1
  22. data/lib/rubyn_code/cli/repl_commands.rb +1 -1
  23. data/lib/rubyn_code/cli/repl_setup.rb +8 -6
  24. data/lib/rubyn_code/config/defaults.rb +2 -1
  25. data/lib/rubyn_code/config/schema.json +5 -0
  26. data/lib/rubyn_code/config/settings.rb +4 -2
  27. data/lib/rubyn_code/context/auto_compact.rb +1 -1
  28. data/lib/rubyn_code/context/manual_compact.rb +1 -1
  29. data/lib/rubyn_code/index/codebase_index.rb +64 -3
  30. data/lib/rubyn_code/index/prism_extractor.rb +82 -0
  31. data/lib/rubyn_code/learning/injector.rb +1 -2
  32. data/lib/rubyn_code/llm/adapters/anthropic.rb +107 -17
  33. data/lib/rubyn_code/llm/adapters/anthropic_streaming.rb +13 -0
  34. data/lib/rubyn_code/llm/adapters/base.rb +2 -1
  35. data/lib/rubyn_code/llm/adapters/openai.rb +1 -1
  36. data/lib/rubyn_code/llm/adapters/openai_message_translator.rb +21 -0
  37. data/lib/rubyn_code/llm/client.rb +16 -3
  38. data/lib/rubyn_code/llm/image_reader.rb +60 -0
  39. data/lib/rubyn_code/llm/message_builder.rb +21 -1
  40. data/lib/rubyn_code/llm/model_router.rb +4 -4
  41. data/lib/rubyn_code/mcp/discovery.rb +93 -0
  42. data/lib/rubyn_code/memory/session_persistence.rb +1 -1
  43. data/lib/rubyn_code/observability/cost_calculator.rb +6 -3
  44. data/lib/rubyn_code/protocols/RUBYN.md +0 -3
  45. data/lib/rubyn_code/tasks/models.rb +0 -16
  46. data/lib/rubyn_code/teams/teammate.rb +0 -15
  47. data/lib/rubyn_code/tools/RUBYN.md +2 -2
  48. data/lib/rubyn_code/tools/bash.rb +3 -3
  49. data/lib/rubyn_code/tools/code_graph.rb +134 -0
  50. data/lib/rubyn_code/tools/executor.rb +4 -1
  51. data/lib/rubyn_code/tools/todo_store.rb +55 -0
  52. data/lib/rubyn_code/tools/todo_write.rb +88 -0
  53. data/lib/rubyn_code/version.rb +1 -1
  54. data/lib/rubyn_code.rb +13 -8
  55. data/skills/rubyn_self_test.md +140 -0
  56. metadata +10 -7
  57. data/lib/rubyn_code/context/context_budget.rb +0 -183
  58. data/lib/rubyn_code/context/schema_filter.rb +0 -64
  59. data/lib/rubyn_code/learning/shortcut.rb +0 -95
  60. data/lib/rubyn_code/llm/adapters/token_caching.rb +0 -54
  61. data/lib/rubyn_code/llm/streaming.rb +0 -10
  62. data/lib/rubyn_code/protocols/plan_approval.rb +0 -72
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f5cbf6f4737790408893a904d64412c2dd695e81a2cd2e5960b4da3196e48f4f
4
- data.tar.gz: f0e9b80f10c77d6eb6c357db7518851344670653546d5eca2784d017d490608c
3
+ metadata.gz: 7696c779b900fe4dca806aa73d7fcca79309ebe58a242d16e2d5309b43d44ec4
4
+ data.tar.gz: 2d8d4c8905255ce11e98569f86f4c2d788c620ea30c513fd6771b3c58bb98073
5
5
  SHA512:
6
- metadata.gz: bc4553f04fbac43903e1515cec43cf595245d36223bca70342ddafb2a2920f38f502b356eb353b4b6e5284d128688e07c015e0a3ebb4aa06f7029fac0ba42a37
7
- data.tar.gz: b9f8ab09a40e073bff1657ef9d829b7551a5ca56ce9f77b65bd59b835d079665d3663faf92ccf44fd84d4ff3674f835c09d102b4f449c175c77bcafb466dd934
6
+ metadata.gz: c5d2b5f15b21d7e02b2f6c2252248eb6e933337b3f4c491135f5453c1ddf4ce47c48605aaeb6e26180a38324c8effc7a149e528c794ca9391ec8c43febbc2bdb
7
+ data.tar.gz: 15c9aa06c02dd8c105116b403cba0f36cb5a2b91c8690b66b6320a289011cf6f2986a308fdaacad933fbd221646de84b7f774a47709a7c8409fdc5bddca5b671
data/README.md CHANGED
@@ -15,7 +15,7 @@
15
15
  <a href="https://github.com/MatthewSuttles/rubyn-code/actions/workflows/ci.yml"><img src="https://github.com/MatthewSuttles/rubyn-code/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
16
16
  </p>
17
17
 
18
- Refactor controllers, generate idiomatic RSpec, catch N+1 queries, review code for anti-patterns, and build entire features — all context-aware with your schema, routes, and specs. Powered by Claude Opus 4.6, running on your existing Claude subscription.
18
+ Refactor controllers, generate idiomatic RSpec, catch N+1 queries, review code for anti-patterns, and build entire features — all context-aware with your schema, routes, and specs. Powered by Claude Opus 4.8, running on your existing Claude subscription.
19
19
 
20
20
  <img width="1230" height="280" alt="image" src="https://github.com/user-attachments/assets/14e07ce8-def0-4a8f-ac89-46661361a4eb" />
21
21
 
@@ -69,6 +69,22 @@ Refactor controllers, generate idiomatic RSpec, catch N+1 queries, review code f
69
69
  - **IDE-ready** — works in the terminal and inside VS Code with full bidirectional communication
70
70
  - **Extensible** — connect external tool servers via MCP, add custom skills, or wire up your own providers
71
71
 
72
+ ## Claude Code Feature Parity
73
+
74
+ Phase 4 ships nine parity features with Claude Code:
75
+
76
+ - **Extended thinking** — `/think <budget>` toggles per-session reasoning; the Anthropic adapter emits `thinking: {type: 'adaptive'}` on Claude 4.6+ models (`{type: 'enabled', budget_tokens}` on older ones).
77
+ - **Reasoning effort** — `/effort <low|medium|high|xhigh|max>` sets the request-level reasoning depth; the Anthropic adapter emits `output_config: {effort}` on the wire. GA on Claude 4.6+ (Opus 4.6/4.7/4.8, Sonnet 4.6/5, Fable 5); `xhigh` needs Opus 4.7+ / Sonnet 5 / Fable 5. Model default is `high`.
78
+ - **Task budgets (beta)** — the agent loop's remaining token budget is sent as `output_config: {task_budget: {type: 'tokens', total}}` on supported models (Fable 5, Sonnet 5, Opus 4.7/4.8), advisory pacing only — `max_tokens` stays the enforced per-response cap.
79
+ - **Image / vision input** — `@chart.png` (and `.jpg` / `.jpeg` / `.gif` / `.webp`) becomes a real image content block attached to the user turn; Anthropic and OpenAI each emit their native shape.
80
+ - **TodoWrite live checklist** — `TodoWrite` tool; the checklist refreshes above the spinner on every tool result so you see in-turn progress at a glance.
81
+ - **Custom-command frontmatter** — `argument-hint`, `allowed-tools`, and `model:` keys in `~/.rubyn-code/commands/*.md`; the loop honors the per-prompt tool restriction and model override.
82
+ - **`.mcp.json` auto-discovery** — project-root MCP servers flow into the REPL automatically; `/mcp` shows entries prefixed `[project]` or `[user]`. OAuth for URL transports is still deferred.
83
+ - **`/export` transcript** — `/export <path>` writes the conversation as Markdown (default) or `--jsonl` for tooling.
84
+ - **Fable 5 refusal handling** — a declined request (`stop_reason: "refusal"`) surfaces a clear message instead of an empty turn; `claude-fable-5` / `claude-mythos-5` requests opt into a server-side Opus 4.8 fallback by default, so most refusals are transparently re-served in the same call.
85
+
86
+ See `docs/04-feature-parity/` for per-feature design + tasks docs.
87
+
72
88
  ## Install
73
89
 
74
90
  Requires **Ruby 4.0.2+**. Install with your latest Ruby, then pin it so it works in every project:
@@ -14,7 +14,10 @@ module RubynCode
14
14
 
15
15
  # Append a user turn to the conversation.
16
16
  #
17
- # @param content [String]
17
+ # @param content [String, Array<Hash>] either a plain string or an
18
+ # array of content blocks (text, image, etc.). Strings get wrapped
19
+ # as `[{ type: 'text', text: content }]` only if there's a planned
20
+ # Array; we keep raw String for backward compatibility with the API.
18
21
  # @return [Hash] the appended message
19
22
  def add_user_message(content)
20
23
  message = { role: 'user', content: content }
@@ -155,6 +158,13 @@ module RubynCode
155
158
  reset_derived_state!
156
159
  end
157
160
 
161
+ # Alias for `messages`. Common shorthand for callers that want to
162
+ # treat the conversation as an Array of message hashes.
163
+ def to_a
164
+ @messages
165
+ end
166
+ alias to_ary to_a
167
+
158
168
  private
159
169
 
160
170
  # Derived bookkeeping kept in sync with @messages so hot paths stay
@@ -8,7 +8,7 @@ module RubynCode
8
8
  # prompt overhead by 30-50%.
9
9
  module DynamicToolSchema
10
10
  BASE_TOOLS = %w[
11
- read_file write_file edit_file glob grep bash
11
+ read_file write_file edit_file glob grep code_graph bash
12
12
  ].freeze
13
13
 
14
14
  TASK_TOOLS = {
@@ -25,7 +25,7 @@ module RubynCode
25
25
  def build_llm_opts
26
26
  opts = {
27
27
  messages: @conversation.to_api_format,
28
- tools: @plan_mode ? read_only_tool_definitions : tool_definitions,
28
+ tools: filtered_tool_definitions,
29
29
  system: build_system_prompt,
30
30
  on_text: @on_text
31
31
  }
@@ -36,6 +36,8 @@ module RubynCode
36
36
  total: UsageTracker::TASK_BUDGET_TOTAL, remaining: @task_budget_remaining
37
37
  }
38
38
  end
39
+ budget = @llm_client.respond_to?(:thinking_budget_tokens) ? @llm_client.thinking_budget_tokens.to_i : 0
40
+ opts[:thinking] = { budget_tokens: budget } if budget.positive?
39
41
  opts
40
42
  end
41
43
 
@@ -45,6 +47,8 @@ module RubynCode
45
47
  # Falls back to nil (use client's default) if routing fails.
46
48
  # -- guard clauses for provider/mode checks
47
49
  def routed_model
50
+ return @model_override if @model_override
51
+
48
52
  return nil if manual_model_mode?
49
53
 
50
54
  last_user = last_user_message_text
@@ -53,8 +53,11 @@ module RubynCode
53
53
  # response is produced or the iteration limit is reached.
54
54
  #
55
55
  # @param user_input [String]
56
+ # @param blocks [Array<Hash>, nil] extra content blocks to attach to the
57
+ # user message (e.g. image blocks from @-mentions). When non-empty the
58
+ # user message is stored as a mixed content array instead of a string.
56
59
  # @return [String] the final assistant text response
57
- def send_message(user_input)
60
+ def send_message(user_input, blocks: nil)
58
61
  initialize_session!
59
62
  check_user_feedback(user_input)
60
63
  drain_background_notifications
@@ -62,7 +65,7 @@ module RubynCode
62
65
  @decision_compactor&.detect_topic_switch(user_input)
63
66
  @skill_ttl&.tick!
64
67
  autoload_triggered_skills(user_input)
65
- @conversation.add_user_message(user_input)
68
+ append_user_message(user_input, blocks)
66
69
  reset_system_prompt_cache!
67
70
  reset_iteration_state
68
71
 
@@ -79,8 +82,38 @@ module RubynCode
79
82
  max_iterations_warning(iteration)
80
83
  end
81
84
 
85
+ # Append the user turn to the conversation. If image blocks were passed
86
+ # via `blocks:`, the message is stored as a mixed content array with a
87
+ # leading text block and the image blocks appended in order.
88
+ def append_user_message(user_input, blocks)
89
+ if blocks.is_a?(Array) && !blocks.empty?
90
+ content = [{ type: 'text', text: user_input.to_s }, *blocks]
91
+ @conversation.add_user_message(content)
92
+ else
93
+ @conversation.add_user_message(user_input)
94
+ end
95
+ end
96
+
97
+ # @return [Tools::TodoStore] shared checklist store, exposed for the REPL renderer
98
+ attr_reader :todo_store
99
+
100
+ # Apply a one-shot model override for the duration of the next LLM call.
101
+ # Used by `Context#with_optional_model` for custom-command frontmatter.
102
+ def model_override=(model_name)
103
+ stripped = model_name.to_s.strip
104
+ @model_override = stripped.empty? ? nil : stripped
105
+ end
106
+
107
+ # Restrict the available tool set for the next LLM call only.
108
+ # @param allowed [Array<String>, nil] tool names, or nil to clear the override.
109
+ def allowed_tools_override=(allowed)
110
+ @allowed_tools_override = allowed.is_a?(Array) && !allowed.empty? ? allowed : nil
111
+ end
112
+
82
113
  private
83
114
 
115
+ # Overrides the region above. The internal helpers below are private.
116
+
84
117
  # Decide whether the loop should run another iteration after `iteration`
85
118
  # turns. Normally capped at MAX_ITERATIONS, but while a Stop hook (e.g. an
86
119
  # active /goal) is keeping the agent alive we extend up to a hard ceiling
@@ -119,6 +152,8 @@ module RubynCode
119
152
  @tool_wrapper = opts[:tool_wrapper]
120
153
  @decision_compactor = build_decision_compactor
121
154
  @skill_ttl = Skills::TtlManager.new
155
+ @todo_store = opts.fetch(:todo_store, Tools::TodoStore.new)
156
+ @tool_executor.todo_store = @todo_store
122
157
  @session_initialized = false
123
158
  end
124
159
 
@@ -133,6 +168,7 @@ module RubynCode
133
168
  def initialize_session!
134
169
  return if @session_initialized || !@project_root
135
170
 
171
+ @todo_store&.clear
136
172
  @session_initialized = true
137
173
  build_project_profile!
138
174
  build_codebase_index!
@@ -168,13 +204,17 @@ module RubynCode
168
204
  @output_recovery_count = 0
169
205
  @task_budget_remaining = nil
170
206
  @stop_block_active = false # true while a Stop hook keeps us going
207
+ @allowed_tools_override = nil # set by Context#with_allowed_tools
208
+ @model_override = nil # set by Context#with_optional_model
171
209
  end
172
210
 
173
211
  def run_iteration(iteration)
174
212
  log_iteration(iteration)
175
213
  @context_manager.advance_turn!
176
214
  compact_if_needed # ensure context is under threshold before LLM call
177
- response = call_llm
215
+ response = call_llm
216
+ return handle_refusal(response) if extract_stop_reason(response) == 'refusal'
217
+
178
218
  tool_calls = extract_tool_calls(response)
179
219
  log_response(response, tool_calls)
180
220
 
@@ -183,6 +223,20 @@ module RubynCode
183
223
  handle_tool_response(response, tool_calls, iteration)
184
224
  end
185
225
 
226
+ # Claude's safety classifiers declined the request outright (HTTP 200,
227
+ # stop_reason: "refusal"). Surface a clear message instead of falling
228
+ # through to text/empty-response handling, which would misread the
229
+ # empty or partial content as "waiting on background jobs".
230
+ def handle_refusal(response)
231
+ details = extract_stop_details(response)
232
+ category = details.is_a?(Hash) ? (details['category'] || details[:category]) : nil
233
+ RubynCode::Debug.llm("Refusal: category=#{category || 'unknown'}")
234
+
235
+ message = "Claude's safety system declined this request (category: #{category || 'unknown'})."
236
+ @conversation.add_assistant_message([{ type: 'text', text: message }])
237
+ message
238
+ end
239
+
186
240
  def log_iteration(iteration)
187
241
  RubynCode::Debug.loop_tick(
188
242
  "iteration=#{iteration} messages=#{@conversation.length} " \
@@ -61,6 +61,14 @@ module RubynCode
61
61
  end
62
62
  end
63
63
 
64
+ def extract_stop_details(response)
65
+ if response.respond_to?(:stop_details)
66
+ response.stop_details
67
+ elsif response.is_a?(Hash)
68
+ response[:stop_details] || response['stop_details']
69
+ end
70
+ end
71
+
64
72
  def recover_truncated_response(response)
65
73
  @max_tokens_override ||= Config::Defaults::ESCALATED_MAX_OUTPUT_TOKENS
66
74
  @conversation.add_assistant_message(response_content(response))
@@ -101,6 +101,9 @@ module RubynCode
101
101
  return unless index&.nodes&.any?
102
102
 
103
103
  parts << "\n## #{index.to_structural_summary}"
104
+ parts << 'This project has a code graph. To locate or understand code, call `code_graph` FIRST — ' \
105
+ 'it returns matching symbols with source, callers, and callees in one call. ' \
106
+ 'Fall back to grep/glob only when code_graph finds nothing.'
104
107
  rescue StandardError
105
108
  nil
106
109
  end
@@ -52,6 +52,16 @@ module RubynCode
52
52
  Tools::Registry.all.select { |t| PLAN_MODE_RISK_LEVELS.include?(t::RISK_LEVEL) }.map(&:to_schema)
53
53
  end
54
54
 
55
+ # Tool definitions for the next LLM call: plan mode narrows to
56
+ # read-only tools; a per-turn allowed-tools override (custom-command
57
+ # frontmatter, set via Loop#allowed_tools_override=) narrows by name.
58
+ def filtered_tool_definitions
59
+ defs = @plan_mode ? read_only_tool_definitions : tool_definitions
60
+ return defs unless @allowed_tools_override
61
+
62
+ DynamicToolSchema.filter(defs, active_names: @allowed_tools_override)
63
+ end
64
+
55
65
  # -- tool dispatch with budget + signals
56
66
  def process_tool_calls(tool_calls)
57
67
  aggregate_chars = 0
@@ -212,7 +212,7 @@ module RubynCode
212
212
  tokens = extract_token_counts(cm)
213
213
  return if tokens.values.all?(&:zero?)
214
214
 
215
- model = @llm_client.respond_to?(:model) ? @llm_client.model : 'claude-sonnet-4-6'
215
+ model = @llm_client.respond_to?(:model) ? @llm_client.model : 'claude-sonnet-5'
216
216
  @total_cost += Observability::CostCalculator.calculate(model: model, **tokens)
217
217
  rescue StandardError
218
218
  # Non-critical — cost tracking is best-effort
@@ -40,6 +40,33 @@ module RubynCode
40
40
 
41
41
  # @return [Boolean]
42
42
  def plan_mode? = plan_mode
43
+
44
+ # Restrict the tool set for a single agent invocation. The next
45
+ # `send_message` call inside the block sees only the tools in
46
+ # `allowed`; the override is cleared after the call.
47
+ def with_allowed_tools(allowed = nil, &block)
48
+ apply_loop_override(:allowed_tools_override=, allowed, &block)
49
+ end
50
+
51
+ # Apply a one-shot model override for the duration of a block.
52
+ def with_optional_model(model_name = nil, &block)
53
+ apply_loop_override(:model_override=, model_name, &block)
54
+ end
55
+
56
+ private
57
+
58
+ # Set an override on the agent loop (when one is wired in) for the
59
+ # duration of the block, then clear it. Custom commands without
60
+ # a loop (e.g. test doubles) fall through to a plain yield.
61
+ def apply_loop_override(method, value)
62
+ loop = agent_loop
63
+ return yield unless loop.respond_to?(method)
64
+
65
+ loop.public_send(method, value)
66
+ yield
67
+ ensure
68
+ loop&.public_send(method, nil) if loop.respond_to?(method)
69
+ end
43
70
  end
44
71
  end
45
72
  end
@@ -14,13 +14,24 @@ module RubynCode
14
14
  attr_reader :name
15
15
  # @return [String] one-line description for /help
16
16
  attr_reader :description
17
+ # @return [String, nil] argument hint shown next to the name in /help
18
+ attr_reader :argument_hint
19
+ # @return [Array<String>, nil] tool names the command restricts to
20
+ attr_reader :allowed_tools
21
+ # @return [String, nil] model override for this prompt
22
+ attr_reader :model
17
23
  # @return [String, nil] originating file path
18
24
  attr_reader :source
19
25
 
20
- def initialize(name:, description:, body:, source: nil)
26
+ def initialize(name:, description:, body:, # rubocop:disable Metrics/ParameterLists -- explicit kwargs document the frontmatter surface
27
+ source: nil,
28
+ argument_hint: nil, allowed_tools: nil, model: nil)
21
29
  @name = name
22
30
  @description = description
23
31
  @template = CommandTemplate.new(body)
32
+ @argument_hint = argument_hint
33
+ @allowed_tools = allowed_tools
34
+ @model = model
24
35
  @source = source
25
36
  end
26
37
 
@@ -29,11 +40,42 @@ module RubynCode
29
40
  def hidden? = false
30
41
  def all_names = [command_name].freeze
31
42
 
43
+ # Render the help label, including [hint] when frontmatter provides one.
44
+ # The argument_hint is expected to be the placeholder text already wrapped
45
+ # in [ ] (e.g. "[env]") — we don't add another set of brackets.
46
+ def help_label
47
+ hint = @argument_hint.to_s.strip
48
+ hint.empty? ? description : "#{description} #{hint}"
49
+ end
50
+
51
+ # @return [Boolean] true if the frontmatter restricted the tool set
52
+ def restricts_tools?
53
+ !@allowed_tools.nil? && !@allowed_tools.empty?
54
+ end
55
+
56
+ # @return [Boolean] true if the frontmatter overrode the model
57
+ def overrides_model?
58
+ !@model.to_s.strip.empty?
59
+ end
60
+
32
61
  # @param args [Array<String>]
33
62
  # @param ctx [Commands::Context]
34
63
  # @return [nil]
35
64
  def execute(args, ctx)
36
- ctx.send_message(@template.render(args))
65
+ rendered = @template.render(args)
66
+ if restricts_tools?
67
+ ctx.with_allowed_tools(@allowed_tools) do
68
+ ctx.with_optional_model(@model) do
69
+ ctx.send_message(rendered)
70
+ end
71
+ end
72
+ elsif overrides_model?
73
+ ctx.with_optional_model(@model) do
74
+ ctx.send_message(rendered)
75
+ end
76
+ else
77
+ ctx.send_message(rendered)
78
+ end
37
79
  nil
38
80
  end
39
81
  end
@@ -40,9 +40,18 @@ module RubynCode
40
40
  name = File.basename(path, '.md').strip
41
41
  return nil unless name.match?(NAME)
42
42
 
43
- description, body = parse(File.read(path))
44
- description = "Custom command: /#{name}" if description.to_s.strip.empty?
45
- CustomCommand.new(name: name, description: description, body: body, source: path)
43
+ meta, body = parse(File.read(path))
44
+ description = meta['description'].to_s
45
+ description = "Custom command: /#{name}" if description.strip.empty?
46
+ CustomCommand.new(
47
+ name: name,
48
+ description: description,
49
+ body: body,
50
+ source: path,
51
+ argument_hint: blank_to_nil(stringify(meta['argument_hint'] || meta['argument-hint'])),
52
+ allowed_tools: list_of_tools(meta['allowed_tools'] || meta['allowed-tools']),
53
+ model: blank_to_nil(stringify(meta['model']))
54
+ )
46
55
  rescue StandardError => e
47
56
  RubynCode::Debug.warn("Failed to load custom command #{path}: #{e.message}")
48
57
  nil
@@ -51,13 +60,35 @@ module RubynCode
51
60
  def parse(content)
52
61
  if (match = FRONTMATTER.match(content))
53
62
  frontmatter = YAML.safe_load(match[1]) || {}
54
- [frontmatter['description'].to_s, match[2].to_s.strip]
63
+ [frontmatter, match[2].to_s.strip]
55
64
  else
56
65
  body = content.to_s.strip
57
- [first_line(body), body]
66
+ [{ 'description' => first_line(body) }, body]
58
67
  end
59
68
  end
60
69
 
70
+ def blank_to_nil(value)
71
+ value.to_s.strip.empty? ? nil : value
72
+ end
73
+
74
+ def stringify(value)
75
+ return '' if value.nil?
76
+
77
+ # YAML's flow-style `[env]` is parsed as a single-element array.
78
+ # Stringify the array element directly so users see exactly the
79
+ # characters they typed.
80
+ str = value.is_a?(Array) ? value.join : value
81
+ str.to_s.strip
82
+ end
83
+
84
+ def list_of_tools(value)
85
+ return nil if value.nil?
86
+
87
+ arr = value.is_a?(Array) ? value : value.to_s.split(',')
88
+ cleaned = arr.map { |name| name.to_s.strip }.reject(&:empty?)
89
+ cleaned.empty? ? nil : cleaned
90
+ end
91
+
61
92
  def first_line(body)
62
93
  line = body.lines.first.to_s.strip
63
94
  line.sub(/\A#+\s*/, '')[0, 80]
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubynCode
4
+ module CLI
5
+ module Commands
6
+ # Set or clear the reasoning effort level (output_config.effort).
7
+ #
8
+ # /effort # show current value
9
+ # /effort off # clear back to model default
10
+ # /effort <level> # low | medium | high | xhigh | max
11
+ class Effort < Base
12
+ LEVELS = %w[low medium high xhigh max].freeze
13
+
14
+ def self.command_name = '/effort'
15
+ def self.description = 'Set reasoning effort (low/medium/high/xhigh/max)'
16
+
17
+ def execute(args, ctx)
18
+ arg = args.first
19
+
20
+ current = ctx.llm_client.respond_to?(:effort) ? ctx.llm_client.effort : nil
21
+
22
+ case arg
23
+ when nil
24
+ show_status(current, ctx)
25
+ when 'off'
26
+ ctx.llm_client.effort = nil
27
+ ctx.renderer.info('Reasoning effort cleared — using model default 🎚️')
28
+ when *LEVELS
29
+ ctx.llm_client.effort = arg
30
+ ctx.renderer.info("Reasoning effort set to #{arg} 🎚️")
31
+ else
32
+ ctx.renderer.warning("Usage: /effort [off|#{LEVELS.join('|')}]. Got: #{arg}")
33
+ end
34
+ end
35
+
36
+ private
37
+
38
+ def show_status(current, ctx)
39
+ state = current || 'not set (model default: high on 4.6+)'
40
+ ctx.renderer.info("Reasoning effort: #{state}")
41
+ ctx.renderer.info("Usage: /effort <level> levels: #{LEVELS.join(', ')} (or /effort off)")
42
+ ctx.renderer.info('Supported on Claude 4.6+ models; xhigh requires Opus 4.7+ / Sonnet 5 / Fable 5.')
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,174 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'json'
5
+
6
+ module RubynCode
7
+ module CLI
8
+ module Commands
9
+ # Export the current conversation transcript to a file.
10
+ #
11
+ # /export path/to/transcript.md # markdown (default)
12
+ # /export --format jsonl path/to/transcript.jsonl
13
+ # /export path/to/transcript.md --force # overwrite existing
14
+ class Export < Base
15
+ def self.command_name = '/export'
16
+ def self.description = 'Export conversation transcript to markdown or JSONL'
17
+
18
+ MAX_LINE_LENGTH = 100
19
+
20
+ # -- CLI arg parsing is intrinsically procedural
21
+ def execute(args, ctx)
22
+ opts = parse_args(args)
23
+ return unless opts
24
+
25
+ messages = ctx.conversation.to_a
26
+ if messages.empty?
27
+ ctx.renderer.warning('No messages to export.')
28
+ return
29
+ end
30
+
31
+ path = File.expand_path(opts[:path])
32
+ dir = File.dirname(path)
33
+ FileUtils.mkdir_p(dir) unless File.directory?(dir)
34
+
35
+ if File.exist?(path) && !opts[:force] && !tty_yes?(ctx, "File exists at #{path}. Overwrite? [y/N]")
36
+ ctx.renderer.info('Export cancelled.')
37
+ return
38
+ end
39
+
40
+ body = opts[:format] == 'jsonl' ? render_jsonl(messages) : render_markdown(messages)
41
+ File.write(path, body)
42
+ plural = messages.size == 1 ? '' : 's'
43
+ ctx.renderer.info("Exported #{messages.size} message#{plural} to #{path}")
44
+ end
45
+
46
+ private
47
+
48
+ # @return [Hash{Symbol=>Object}, nil] nil on bad input
49
+ # -- arg parsing is intrinsically branchy
50
+ def parse_args(args) # rubocop:disable Metrics/MethodLength -- arg parsing is intrinsically procedural
51
+ format = 'markdown'
52
+ force = false
53
+ path = nil
54
+
55
+ if (idx = args.index('--format') || args.index('-f'))
56
+ format = args[idx + 1].to_s
57
+ end
58
+ format = case format
59
+ when 'md' then 'markdown'
60
+ else format
61
+ end
62
+
63
+ args.each do |a|
64
+ case a
65
+ when '--jsonl'
66
+ format = 'jsonl'
67
+ when '--markdown', '--md'
68
+ format = 'markdown'
69
+ when '--format', '-f'
70
+ # already handled above
71
+ when '--force', '-y'
72
+ force = true
73
+ when /\A--.+/
74
+ next
75
+ else
76
+ path ||= a
77
+ end
78
+ end
79
+
80
+ return nil if path.to_s.empty?
81
+
82
+ { path: path, format: format, force: force }
83
+ end
84
+
85
+ def render_markdown(messages)
86
+ title = 'Rubyn transcript'
87
+ buffer = String.new
88
+ buffer << "# #{title}\n\n"
89
+ buffer << "_Exported at #{Time.now.strftime('%Y-%m-%d %H:%M:%S')}_\n\n"
90
+ messages.each { |msg| render_markdown_message(buffer, msg) }
91
+ buffer
92
+ end
93
+
94
+ def render_markdown_message(buffer, msg)
95
+ role = msg[:role] || msg['role']
96
+ case role
97
+ when 'user'
98
+ text = render_user_text(msg[:content] || msg['content'])
99
+ buffer << "## User\n\n#{text}\n\n"
100
+ when 'assistant'
101
+ render_assistant_blocks(buffer, msg[:content] || msg['content'])
102
+ when 'tool'
103
+ buffer << "### tool result\n\n```\n#{msg[:content] || msg['content']}\n```\n\n"
104
+ end
105
+ end
106
+
107
+ def render_user_text(content)
108
+ blocks = normalize_content(content)
109
+ if blocks.any? { |b| block_type(b) == 'image' }
110
+ blocks.map { |b| block_type(b) == 'image' ? '_(image attachment)_' : text_of(b) }.join("\n")
111
+ else
112
+ blocks.map { |b| text_of(b) }.join("\n")
113
+ end
114
+ end
115
+
116
+ def render_assistant_blocks(buffer, content)
117
+ blocks = normalize_content(content)
118
+ if blocks.empty?
119
+ buffer << "## Assistant\n\n_(empty response)_\n\n"
120
+ return
121
+ end
122
+ blocks.each do |b|
123
+ render_assistant_block(buffer, b)
124
+ end
125
+ end
126
+
127
+ def render_assistant_block(buffer, b) # rubocop:disable Naming/MethodParameterName -- short names are clearer here than "block"
128
+ case block_type(b)
129
+ when 'text'
130
+ buffer << "## Assistant\n\n#{text_of(b)}\n\n"
131
+ when 'thinking'
132
+ buffer << "<details><summary>thinking</summary>\n\n#{text_of(b)}\n\n</details>\n\n"
133
+ when 'tool_use'
134
+ buffer << "[tool: #{b[:name] || b['name']}]\n\n```json\n#{JSON.pretty_generate(input_of(b))}\n```\n\n"
135
+ end
136
+ end
137
+
138
+ def render_jsonl(messages)
139
+ lines = messages.map do |msg|
140
+ role = msg[:role] || msg['role']
141
+ raw = msg[:content] || msg['content']
142
+ content = raw.is_a?(String) ? raw : normalize_content(raw)
143
+ "#{JSON.generate(role: role, content: content, timestamp: Time.now.utc.iso8601)}\n"
144
+ end
145
+ lines.join
146
+ end
147
+
148
+ # Wrap a String content into a single text block so we can render uniformly.
149
+ def normalize_content(content)
150
+ return [{ type: 'text', text: content.to_s }] if content.is_a?(String)
151
+ return [{ type: 'text', text: '' }] if content.nil?
152
+
153
+ content
154
+ end
155
+
156
+ def block_type(block)
157
+ block[:type] || block['type']
158
+ end
159
+
160
+ def text_of(block)
161
+ (block[:text] || block['text']).to_s
162
+ end
163
+
164
+ def input_of(block)
165
+ (block[:input] || block['input']) || {}
166
+ end
167
+
168
+ def tty_yes?(ctx, prompt)
169
+ ctx.renderer.respond_to?(:ask) ? ctx.renderer.ask(prompt, default: false) : true
170
+ end
171
+ end
172
+ end
173
+ end
174
+ end