ruby_coded 0.3.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +20 -0
  3. data/README.md +113 -1
  4. data/lib/ruby_coded/chat/app.rb +35 -34
  5. data/lib/ruby_coded/chat/bridge_common/auto_switch.rb +45 -0
  6. data/lib/ruby_coded/chat/bridge_common/mode_transitions.rb +70 -0
  7. data/lib/ruby_coded/chat/bridge_common/tool_flow.rb +61 -0
  8. data/lib/ruby_coded/chat/bridge_common.rb +31 -0
  9. data/lib/ruby_coded/chat/bridge_factory.rb +61 -0
  10. data/lib/ruby_coded/chat/codex_bridge/error_handling.rb +1 -20
  11. data/lib/ruby_coded/chat/codex_bridge/request_builder.rb +3 -15
  12. data/lib/ruby_coded/chat/codex_bridge/tool_approval.rb +8 -32
  13. data/lib/ruby_coded/chat/codex_bridge/tool_handling.rb +3 -34
  14. data/lib/ruby_coded/chat/codex_bridge.rb +31 -46
  15. data/lib/ruby_coded/chat/command_handler/agent_commands.rb +3 -3
  16. data/lib/ruby_coded/chat/command_handler/plan_commands.rb +1 -1
  17. data/lib/ruby_coded/chat/command_handler/skill_commands.rb +87 -0
  18. data/lib/ruby_coded/chat/command_handler.rb +3 -0
  19. data/lib/ruby_coded/chat/llm_bridge/chat_configuration.rb +38 -0
  20. data/lib/ruby_coded/chat/llm_bridge/plan_mode.rb +3 -32
  21. data/lib/ruby_coded/chat/llm_bridge/tool_call_handling.rb +9 -50
  22. data/lib/ruby_coded/chat/llm_bridge.rb +31 -65
  23. data/lib/ruby_coded/chat/prompt_builder.rb +66 -0
  24. data/lib/ruby_coded/chat/renderer/chat_panel.rb +48 -62
  25. data/lib/ruby_coded/chat/renderer/chat_panel_cache.rb +35 -0
  26. data/lib/ruby_coded/chat/renderer/chat_panel_formatting.rb +72 -0
  27. data/lib/ruby_coded/chat/renderer/chat_panel_layout.rb +81 -0
  28. data/lib/ruby_coded/chat/renderer/chat_panel_sections.rb +93 -0
  29. data/lib/ruby_coded/chat/renderer/chat_panel_thinking.rb +20 -34
  30. data/lib/ruby_coded/chat/renderer/chat_panel_thinking_render.rb +49 -0
  31. data/lib/ruby_coded/chat/renderer/rich_text.rb +92 -0
  32. data/lib/ruby_coded/chat/renderer/rich_text_inline.rb +81 -0
  33. data/lib/ruby_coded/chat/renderer.rb +12 -0
  34. data/lib/ruby_coded/chat/runtime_mode.rb +97 -0
  35. data/lib/ruby_coded/commands/core_provider.rb +7 -0
  36. data/lib/ruby_coded/commands.rb +11 -0
  37. data/lib/ruby_coded/plugins.rb +10 -1
  38. data/lib/ruby_coded/skills/catalog.rb +103 -0
  39. data/lib/ruby_coded/skills/markdown_loader.rb +112 -0
  40. data/lib/ruby_coded/skills/prompt_formatter.rb +42 -0
  41. data/lib/ruby_coded/skills/skill_definition.rb +29 -0
  42. data/lib/ruby_coded/skills.rb +18 -0
  43. data/lib/ruby_coded/tools/base_tool.rb +14 -0
  44. data/lib/ruby_coded/tools/delete_path_tool.rb +5 -8
  45. data/lib/ruby_coded/tools/edit_file_tool.rb +6 -8
  46. data/lib/ruby_coded/tools/execution_pipeline.rb +60 -0
  47. data/lib/ruby_coded/tools/execution_policy.rb +89 -0
  48. data/lib/ruby_coded/tools/write_file_tool.rb +6 -9
  49. data/lib/ruby_coded/version.rb +1 -1
  50. data/lib/ruby_coded.rb +1 -0
  51. metadata +25 -2
@@ -68,7 +68,14 @@ module RubyCoded
68
68
  end
69
69
 
70
70
  def format_thinking_text(cycle_messages)
71
- cycle_messages.map { |m| format_thinking_message(m) }.join("\n")
71
+ rich_text_plain(format_thinking_lines(cycle_messages))
72
+ end
73
+
74
+ def format_thinking_lines(cycle_messages)
75
+ cycle_messages.flat_map.with_index do |msg, index|
76
+ lines = format_thinking_message_rich(msg)
77
+ index == cycle_messages.length - 1 ? lines : lines + [@tui.line(spans: [@tui.span(content: "")])]
78
+ end
72
79
  end
73
80
 
74
81
  def format_thinking_message(msg)
@@ -82,41 +89,20 @@ module RubyCoded
82
89
  end
83
90
  end
84
91
 
85
- def render_chat_with_thinking(frame, area, messages)
86
- full_cycle = current_cycle_messages(messages)
87
- cycle = tail_of_cycle(full_cycle)
88
- prior = messages[0...(messages.length - full_cycle.length)]
89
- chat_area, thinking_area = split_chat_thinking(area)
90
-
91
- render_messages_in_area(frame, chat_area, prior)
92
- render_thinking_panel(frame, thinking_area, format_thinking_text(cycle))
93
- end
94
-
95
- def split_chat_thinking(area)
96
- @tui.layout_split(
97
- area,
98
- direction: :vertical,
99
- constraints: [@tui.constraint_fill(3), @tui.constraint_fill(2)]
100
- )
92
+ def format_thinking_message_rich(msg)
93
+ text, role = thinking_rich_text_and_role(msg)
94
+ rich_text_lines(text, role: role)
101
95
  end
102
96
 
103
- def render_thinking_panel(frame, area, thinking_text)
104
- scroll_y = thinking_scroll_y(area, thinking_text)
105
-
106
- widget = @tui.paragraph(
107
- text: thinking_text,
108
- wrap: true,
109
- scroll: [scroll_y, 0],
110
- block: @tui.block(title: "thinking...", borders: [:all])
111
- )
112
- frame.render_widget(widget, area)
113
- end
114
-
115
- def thinking_scroll_y(area, text)
116
- inner_height = [area.height - 2, 0].max
117
- inner_width = [area.width - 2, 0].max
118
- total_lines = count_wrapped_lines(text, inner_width)
119
- [total_lines - inner_height, 0].max
97
+ def thinking_rich_text_and_role(msg)
98
+ case msg[:role]
99
+ when :assistant then [msg[:content].gsub(%r{</?think>}, ""), :assistant]
100
+ when :tool_call then [">> #{msg[:content]}", :tool_call]
101
+ when :tool_pending then ["?? #{msg[:content]}", :tool_pending]
102
+ when :tool_result then [" #{msg[:content]}", :tool_result]
103
+ when :system then ["--- #{msg[:content]}", :system]
104
+ else [msg[:content].to_s, :assistant]
105
+ end
120
106
  end
121
107
  end
122
108
  end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyCoded
4
+ module Chat
5
+ class Renderer
6
+ # Layout and widget rendering for the split chat/thinking panel view.
7
+ module ChatPanelThinkingRender
8
+ private
9
+
10
+ def render_chat_with_thinking(frame, area, messages)
11
+ full_cycle = current_cycle_messages(messages)
12
+ cycle = tail_of_cycle(full_cycle)
13
+ prior = messages[0...(messages.length - full_cycle.length)]
14
+ chat_area, thinking_area = split_chat_thinking(area)
15
+
16
+ render_messages_in_area(frame, chat_area, prior)
17
+ render_thinking_panel(frame, thinking_area, format_thinking_lines(cycle))
18
+ end
19
+
20
+ def split_chat_thinking(area)
21
+ @tui.layout_split(
22
+ area,
23
+ direction: :vertical,
24
+ constraints: [@tui.constraint_fill(3), @tui.constraint_fill(2)]
25
+ )
26
+ end
27
+
28
+ def render_thinking_panel(frame, area, thinking_lines)
29
+ scroll_y = thinking_scroll_y(area, thinking_lines)
30
+
31
+ widget = @tui.paragraph(
32
+ text: thinking_lines,
33
+ wrap: true,
34
+ scroll: [scroll_y, 0],
35
+ block: @tui.block(title: "thinking...", borders: [:all])
36
+ )
37
+ frame.render_widget(widget, area)
38
+ end
39
+
40
+ def thinking_scroll_y(area, text_or_lines)
41
+ inner_height = [area.height - 2, 0].max
42
+ inner_width = [area.width - 2, 0].max
43
+ total_lines = count_wrapped_lines(text_or_lines, inner_width)
44
+ [total_lines - inner_height, 0].max
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "rich_text_inline"
4
+
5
+ module RubyCoded
6
+ module Chat
7
+ class Renderer
8
+ # Helpers for building and serializing rich text using ratatui text lines/spans.
9
+ module RichText
10
+ include RichTextInline
11
+
12
+ def rich_text_lines(text, role: :assistant)
13
+ return [] if text.nil? || text.empty?
14
+
15
+ parse_rich_text(text, role: role)
16
+ end
17
+
18
+ def rich_text_plain(lines)
19
+ Array(lines).map { |line| rich_line_plain(line) }.join("\n")
20
+ end
21
+
22
+ def rich_line_plain(line)
23
+ return line.to_s unless line.respond_to?(:spans)
24
+
25
+ Array(line.spans).map { |span| span.respond_to?(:content) ? span.content.to_s : span.to_s }.join
26
+ end
27
+
28
+ def parse_rich_text(text, role: :assistant)
29
+ state = { lines: [], in_code_block: false }
30
+ text.split("\n", -1).each { |raw_line| parse_rich_line(raw_line, state, role) }
31
+ state[:lines]
32
+ end
33
+
34
+ def parse_rich_line(raw_line, state, role)
35
+ return toggle_code_fence(raw_line, state) if raw_line.start_with?("```")
36
+
37
+ state[:lines] << rich_line_for(raw_line, state[:in_code_block], role)
38
+ end
39
+
40
+ def toggle_code_fence(raw_line, state)
41
+ if state[:in_code_block]
42
+ state[:in_code_block] = false
43
+ return
44
+ end
45
+
46
+ state[:in_code_block] = true
47
+ language = raw_line.delete_prefix("```").strip
48
+ state[:lines] << code_language_line(language) unless language.empty?
49
+ end
50
+
51
+ def rich_line_for(raw_line, in_code_block, role)
52
+ return @tui.line(spans: inline_spans(raw_line, role: role)) unless in_code_block
53
+
54
+ @tui.line(spans: [@tui.span(content: raw_line, style: code_block_style)])
55
+ end
56
+
57
+ def code_language_line(language)
58
+ @tui.line(spans: [@tui.span(content: "[#{language}]", style: @tui.style(fg: :yellow, modifiers: [:bold]))])
59
+ end
60
+
61
+ def merge_style(style, foreground: nil, background: nil, modifiers: [])
62
+ @tui.style(
63
+ fg: foreground || style&.fg,
64
+ bg: background || style&.bg,
65
+ modifiers: ((style&.modifiers || []) + modifiers).uniq
66
+ )
67
+ end
68
+
69
+ BASE_TEXT_STYLES = {
70
+ system: { fg: :dark_gray, modifiers: [:italic] },
71
+ user_label: { fg: :cyan, modifiers: [:bold] },
72
+ tool_call: { fg: :yellow, modifiers: [:bold] },
73
+ tool_pending: { fg: :magenta, modifiers: [:bold] },
74
+ tool_result: { fg: :green },
75
+ thinking_title: { fg: :blue, modifiers: [:bold] }
76
+ }.freeze
77
+
78
+ def base_text_style(role)
79
+ @tui.style(**BASE_TEXT_STYLES.fetch(role, { modifiers: [] }))
80
+ end
81
+
82
+ def inline_code_style
83
+ @tui.style(fg: :yellow, bg: :dark_gray)
84
+ end
85
+
86
+ def code_block_style
87
+ @tui.style(fg: :green, bg: :black)
88
+ end
89
+ end
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyCoded
4
+ module Chat
5
+ class Renderer
6
+ # Inline markdown parsing (code, bold, italic) into ratatui spans.
7
+ module RichTextInline
8
+ def inline_spans(text, role: :assistant)
9
+ spans = []
10
+ remaining = text.dup
11
+
12
+ while !remaining.empty?
13
+ marker, index = next_inline_marker(remaining)
14
+ unless marker
15
+ spans << @tui.span(content: remaining, style: base_text_style(role))
16
+ break
17
+ end
18
+
19
+ prefix = remaining[0...index]
20
+ spans << @tui.span(content: prefix, style: base_text_style(role)) unless prefix.empty?
21
+
22
+ consumed = append_styled_span!(spans, remaining[index..], role: role)
23
+ if consumed
24
+ remaining = remaining[(index + consumed)..]
25
+ else
26
+ spans << @tui.span(content: remaining[index], style: base_text_style(role))
27
+ remaining = remaining[(index + 1)..]
28
+ end
29
+ end
30
+
31
+ spans = [@tui.span(content: "", style: base_text_style(role))] if spans.empty?
32
+ spans
33
+ end
34
+
35
+ def next_inline_marker(text)
36
+ markers = ["`", "**", "*"]
37
+ positions = markers.filter_map do |marker|
38
+ index = text.index(marker)
39
+ [marker, index] if index
40
+ end
41
+ positions.min_by { |(_, index)| index }
42
+ end
43
+
44
+ def append_styled_span!(spans, text, role: :assistant)
45
+ return append_code_span!(spans, text) if text.start_with?("`")
46
+ return append_bold_span!(spans, text, role: role) if text.start_with?("**")
47
+ return append_italic_span!(spans, text, role: role) if text.start_with?("*")
48
+
49
+ nil
50
+ end
51
+
52
+ def append_code_span!(spans, text)
53
+ close = text.index("`", 1)
54
+ return nil unless close
55
+
56
+ content = text[1...close]
57
+ spans << @tui.span(content: content, style: inline_code_style)
58
+ close + 1
59
+ end
60
+
61
+ def append_bold_span!(spans, text, role: :assistant)
62
+ close = text.index("**", 2)
63
+ return nil unless close
64
+
65
+ content = text[2...close]
66
+ spans << @tui.span(content: content, style: merge_style(base_text_style(role), modifiers: [:bold]))
67
+ close + 2
68
+ end
69
+
70
+ def append_italic_span!(spans, text, role: :assistant)
71
+ close = text.index("*", 1)
72
+ return nil unless close
73
+
74
+ content = text[1...close]
75
+ spans << @tui.span(content: content, style: merge_style(base_text_style(role), modifiers: [:italic]))
76
+ close + 1
77
+ end
78
+ end
79
+ end
80
+ end
81
+ end
@@ -1,7 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "renderer/rich_text"
4
+ require_relative "renderer/chat_panel_formatting"
5
+ require_relative "renderer/chat_panel_sections"
6
+ require_relative "renderer/chat_panel_layout"
7
+ require_relative "renderer/chat_panel_cache"
3
8
  require_relative "renderer/chat_panel"
4
9
  require_relative "renderer/chat_panel_thinking"
10
+ require_relative "renderer/chat_panel_thinking_render"
5
11
  require_relative "renderer/chat_panel_input"
6
12
  require_relative "renderer/model_selector"
7
13
  require_relative "renderer/plan_clarifier_layout"
@@ -14,8 +20,14 @@ module RubyCoded
14
20
  module Chat
15
21
  # This class manages the rendering of the UI elements
16
22
  class Renderer
23
+ include RichText
24
+ include ChatPanelFormatting
25
+ include ChatPanelSections
26
+ include ChatPanelLayout
27
+ include ChatPanelCache
17
28
  include ChatPanel
18
29
  include ChatPanelThinking
30
+ include ChatPanelThinkingRender
19
31
  include ChatPanelInput
20
32
  include ModelSelector
21
33
  include PlanClarifierLayout
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyCoded
4
+ module Chat
5
+ # Value object representing the runtime mode of the assistant.
6
+ #
7
+ # Modes are the single source of truth for tool availability,
8
+ # prompt selection, mutation permission, and confirmation policy.
9
+ # Bridges hold a RuntimeMode instance instead of independent
10
+ # boolean flags to avoid inconsistent combinations.
11
+ class RuntimeMode
12
+ NAMES = %i[chat agent plan].freeze
13
+
14
+ attr_reader :name
15
+
16
+ def initialize(name)
17
+ raise ArgumentError, "unknown mode: #{name.inspect}" unless NAMES.include?(name)
18
+
19
+ @name = name
20
+ end
21
+
22
+ def chat?
23
+ @name == :chat
24
+ end
25
+
26
+ def agent?
27
+ @name == :agent
28
+ end
29
+
30
+ def plan?
31
+ @name == :plan
32
+ end
33
+
34
+ # Tools may be invoked in this mode (readonly for plan, full for agent).
35
+ def allows_tools?
36
+ agent? || plan?
37
+ end
38
+
39
+ # Destructive/write tools are permitted only in agent mode.
40
+ def allows_mutation?
41
+ agent?
42
+ end
43
+
44
+ # User confirmation is required for non-safe tools outside chat mode.
45
+ def requires_confirmation?
46
+ agent? || plan?
47
+ end
48
+
49
+ # Symbol used when querying skills for this mode.
50
+ def skill_mode
51
+ @name
52
+ end
53
+
54
+ def to_s
55
+ @name.to_s
56
+ end
57
+
58
+ def ==(other)
59
+ other.is_a?(RuntimeMode) && other.name == @name
60
+ end
61
+ alias eql? ==
62
+
63
+ def hash
64
+ [self.class, @name].hash
65
+ end
66
+
67
+ CHAT = new(:chat)
68
+ AGENT = new(:agent)
69
+ PLAN = new(:plan)
70
+
71
+ class << self
72
+ def chat
73
+ CHAT
74
+ end
75
+
76
+ def agent
77
+ AGENT
78
+ end
79
+
80
+ def plan
81
+ PLAN
82
+ end
83
+
84
+ def for(value)
85
+ return value if value.is_a?(RuntimeMode)
86
+
87
+ case value.to_sym
88
+ when :chat then CHAT
89
+ when :agent then AGENT
90
+ when :plan then PLAN
91
+ else raise ArgumentError, "unknown mode: #{value.inspect}"
92
+ end
93
+ end
94
+ end
95
+ end
96
+ end
97
+ end
@@ -83,6 +83,13 @@ module RubyCoded
83
83
  handler: :cmd_commands,
84
84
  source: :core,
85
85
  usage: "/commands [reload|list]"
86
+ },
87
+ {
88
+ name: "/skills",
89
+ description: "Manage project-local skills",
90
+ handler: :cmd_skills,
91
+ source: :core,
92
+ usage: "/skills [reload|list]"
86
93
  }
87
94
  ].freeze
88
95
 
@@ -1,5 +1,16 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ # Commands are **explicit user-invoked actions**.
4
+ #
5
+ # Every command is triggered by the user typing a leading slash
6
+ # (e.g. `/agent`, `/plan`, `/model`, `/help`, or a project-local
7
+ # markdown command like `/review-auth`). Commands are dispatched by
8
+ # CommandHandler and never fire automatically.
9
+ #
10
+ # See also:
11
+ # - RubyCoded::Skills — automatic prompt overlays (no user typing).
12
+ # - RubyCoded::Plugins — code-level behavioral extensions.
13
+
3
14
  require_relative "commands/command_definition"
4
15
  require_relative "commands/core_provider"
5
16
  require_relative "commands/plugin_provider"
@@ -1,5 +1,15 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ # Plugins are **code-level behavioral extensions** to the chat runtime.
4
+ #
5
+ # A plugin can extend State, InputHandler, Renderer, or CommandHandler
6
+ # through `apply_extensions!`. Plugins may register their own slash
7
+ # commands, but they may also add invisible behavior (e.g. command
8
+ # completion). Contrast with:
9
+ #
10
+ # - RubyCoded::Commands — explicit user-invoked slash actions.
11
+ # - RubyCoded::Skills — automatic prompt overlays for the model.
12
+
3
13
  require_relative "plugins/base"
4
14
  require_relative "plugins/registry"
5
15
 
@@ -17,5 +27,4 @@ end
17
27
 
18
28
  require_relative "plugins/command_completion/plugin"
19
29
 
20
- # Register built-in plugins
21
30
  RubyCoded.register_plugin(RubyCoded::Plugins::CommandCompletion::Plugin)
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "skill_definition"
4
+ require_relative "markdown_loader"
5
+
6
+ module RubyCoded
7
+ module Skills
8
+ # Loads, validates, and caches project-local skill definitions.
9
+ class Catalog
10
+ def initialize(project_root:)
11
+ @project_root = project_root
12
+ @last_reload_report = nil
13
+ end
14
+
15
+ def all_skills
16
+ valid_skills.sort_by { |skill| [-skill.priority, skill.name.downcase] }
17
+ end
18
+
19
+ def skills_for_mode(mode)
20
+ all_skills.select { |skill| skill.applies_to_mode?(mode) }
21
+ end
22
+
23
+ def relevant_skills_for(mode:, input: nil)
24
+ mode_skills = skills_for_mode(mode)
25
+ return mode_skills if input.to_s.strip.empty?
26
+
27
+ matched = mode_skills.select { |skill| matches_input?(skill, input) }
28
+ matched.empty? ? mode_skills : matched.sort_by { |skill| [-skill.priority, skill.name.downcase] }
29
+ end
30
+
31
+ def reload!
32
+ previous_names = cached_skill_names
33
+ clear_cached_reports!
34
+ current_names = valid_skills.map { |skill| skill.name.downcase }
35
+ @last_reload_report = build_reload_report(previous_names, current_names)
36
+ end
37
+
38
+ def last_reload_report
39
+ @last_reload_report || default_reload_report
40
+ end
41
+
42
+ private
43
+
44
+ def cached_skill_names
45
+ return [] unless @valid_skills
46
+
47
+ @valid_skills.map { |skill| skill.name.downcase }
48
+ end
49
+
50
+ def clear_cached_reports!
51
+ @load_report = nil
52
+ @valid_skills = nil
53
+ end
54
+
55
+ def default_reload_report
56
+ build_reload_report([], valid_skills.map { |skill| skill.name.downcase }).merge(added: 0)
57
+ end
58
+
59
+ def build_reload_report(previous_names, current_names)
60
+ {
61
+ total: current_names.size,
62
+ added: (current_names - previous_names).size,
63
+ removed: (previous_names - current_names).size,
64
+ invalid: load_report[:invalid_count],
65
+ invalid_files: load_report[:invalid_files],
66
+ duplicates: duplicate_names.size,
67
+ duplicate_skills: duplicate_names
68
+ }
69
+ end
70
+
71
+ def valid_skills
72
+ @valid_skills ||= load_report[:entries]
73
+ .uniq { |entry| entry[:name].downcase }
74
+ .map { |entry| build_definition(entry) }
75
+ end
76
+
77
+ def duplicate_names
78
+ counts = Hash.new(0)
79
+ load_report[:entries].each { |entry| counts[entry[:name].downcase] += 1 }
80
+ counts.select { |_name, count| count > 1 }.keys.sort
81
+ end
82
+
83
+ def build_definition(entry)
84
+ SkillDefinition.new(**entry)
85
+ end
86
+
87
+ def matches_input?(skill, input)
88
+ haystack = input.to_s.downcase
89
+ trigger_match = !skill.trigger.to_s.empty? && haystack.include?(skill.trigger.downcase)
90
+ tag_match = skill.tags.any? { |tag| haystack.include?(tag.downcase) }
91
+ trigger_match || tag_match
92
+ end
93
+
94
+ def loader
95
+ @loader ||= MarkdownLoader.new(project_root: @project_root)
96
+ end
97
+
98
+ def load_report
99
+ @load_report ||= loader.load_report
100
+ end
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module RubyCoded
6
+ module Skills
7
+ # Loads project-local markdown skill files.
8
+ class MarkdownLoader
9
+ SUPPORTED_MODES = %w[chat plan agent].freeze
10
+
11
+ def initialize(project_root:)
12
+ @project_root = project_root
13
+ end
14
+
15
+ def load_files
16
+ load_report[:entries]
17
+ end
18
+
19
+ def load_report
20
+ return empty_report unless Dir.exist?(skills_dir)
21
+
22
+ build_report(skill_paths)
23
+ end
24
+
25
+ private
26
+
27
+ def empty_report
28
+ { entries: [], invalid_count: 0, invalid_files: [] }
29
+ end
30
+
31
+ def build_report(paths)
32
+ entries, invalid_files = paths.each_with_object([[], []]) do |path, memo|
33
+ collect_report_entry(path, *memo)
34
+ end
35
+
36
+ {
37
+ entries: entries,
38
+ invalid_count: invalid_files.size,
39
+ invalid_files: invalid_files
40
+ }
41
+ end
42
+
43
+ def collect_report_entry(path, entries, invalid_files)
44
+ parsed = parse_file(path)
45
+ parsed ? entries << parsed : invalid_files << File.basename(path)
46
+ end
47
+
48
+ def skill_paths
49
+ Dir.glob(File.join(skills_dir, "*.md"))
50
+ end
51
+
52
+ def skills_dir
53
+ File.join(@project_root, ".rubycoded", "skills")
54
+ end
55
+
56
+ def parse_file(path)
57
+ frontmatter, body = extract_frontmatter(File.read(path))
58
+ return nil unless frontmatter
59
+
60
+ build_entry(path, extract_attributes(frontmatter, body))
61
+ rescue StandardError
62
+ nil
63
+ end
64
+
65
+ def extract_attributes(frontmatter, body)
66
+ data = YAML.safe_load(frontmatter) || {}
67
+ {
68
+ name: data["name"]&.strip,
69
+ description: data["description"]&.strip,
70
+ modes: normalize_modes(data["modes"]),
71
+ trigger: data["trigger"]&.strip,
72
+ priority: data["priority"],
73
+ tags: normalize_tags(data["tags"]),
74
+ content: body.to_s.strip
75
+ }
76
+ end
77
+
78
+ def normalize_modes(value)
79
+ Array(value).map { |mode| mode.to_s.strip.downcase }.reject(&:empty?)
80
+ end
81
+
82
+ def normalize_tags(value)
83
+ Array(value).map { |tag| tag.to_s.strip }.reject(&:empty?)
84
+ end
85
+
86
+ def build_entry(path, attrs)
87
+ return nil unless valid_entry?(attrs)
88
+
89
+ attrs.merge(path: path)
90
+ end
91
+
92
+ def valid_entry?(attrs)
93
+ required_fields_present?(attrs) && valid_modes?(attrs[:modes])
94
+ end
95
+
96
+ def required_fields_present?(attrs)
97
+ %i[name description content].all? { |field| !attrs[field].to_s.empty? }
98
+ end
99
+
100
+ def valid_modes?(modes)
101
+ modes.any? && modes.all? { |mode| SUPPORTED_MODES.include?(mode) }
102
+ end
103
+
104
+ def extract_frontmatter(raw)
105
+ match = raw.match(/\A---\s*\n(.*?)\n---\s*\n?(.*)\z/m)
106
+ return [nil, nil] unless match
107
+
108
+ [match[1], match[2]]
109
+ end
110
+ end
111
+ end
112
+ end