ares-runtime 2.0.1

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 (39) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +10 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +110 -0
  5. data/config/models.yml +25 -0
  6. data/config/ollama.yml +4 -0
  7. data/config/workspaces.yml +6 -0
  8. data/exe/ares +75 -0
  9. data/lib/ares/cli.rb +37 -0
  10. data/lib/ares/runtime/adapters/base_adapter.rb +68 -0
  11. data/lib/ares/runtime/adapters/claude_adapter.rb +35 -0
  12. data/lib/ares/runtime/adapters/codex_adapter.rb +35 -0
  13. data/lib/ares/runtime/adapters/cursor_adapter.rb +32 -0
  14. data/lib/ares/runtime/adapters/ollama_adapter.rb +37 -0
  15. data/lib/ares/runtime/config_cli.rb +18 -0
  16. data/lib/ares/runtime/config_manager.rb +137 -0
  17. data/lib/ares/runtime/context_loader.rb +45 -0
  18. data/lib/ares/runtime/core_subsystem.rb +36 -0
  19. data/lib/ares/runtime/diagnostic_parser.rb +159 -0
  20. data/lib/ares/runtime/doctor.rb +34 -0
  21. data/lib/ares/runtime/engine_chain.rb +108 -0
  22. data/lib/ares/runtime/git_manager.rb +26 -0
  23. data/lib/ares/runtime/initializer.rb +30 -0
  24. data/lib/ares/runtime/logs_cli.rb +35 -0
  25. data/lib/ares/runtime/model_selector.rb +36 -0
  26. data/lib/ares/runtime/ollama_client_factory.rb +43 -0
  27. data/lib/ares/runtime/planner/ollama_planner.rb +51 -0
  28. data/lib/ares/runtime/planner/tiny_task_processor.rb +129 -0
  29. data/lib/ares/runtime/prompt_builder.rb +52 -0
  30. data/lib/ares/runtime/quota_manager.rb +48 -0
  31. data/lib/ares/runtime/router.rb +285 -0
  32. data/lib/ares/runtime/task_logger.rb +37 -0
  33. data/lib/ares/runtime/task_manager.rb +9 -0
  34. data/lib/ares/runtime/terminal_runner.rb +37 -0
  35. data/lib/ares/runtime/tui.rb +211 -0
  36. data/lib/ares/runtime/version.rb +7 -0
  37. data/lib/ares/runtime.rb +5 -0
  38. data/lib/ares_runtime.rb +63 -0
  39. metadata +240 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: f1e365203d1a9e9c5744f269c549442361f39cf36da8525f311bae426947b58e
4
+ data.tar.gz: 96fc6b9900f2bea091cd6f90331eb09c51a7f751c5c42810fd9465e2d797d1b3
5
+ SHA512:
6
+ metadata.gz: a0a770802c524ee3dbdadda2b8d0842e23d19c2c42943a763151a20cdc185fe6a11d0326b925fdaa907d9e165696df290a9c81cc3c3e16749f295c8f18deef21
7
+ data.tar.gz: be7b15ebe2c4cd3a4e80aadfdfa3ccddbe0c436876e9e9e115851acd9d2ce1c4b45db728c9c6d1f644e1a5b5ea45f1e9b2c8bf17c459456df036471c940414d0
data/CHANGELOG.md ADDED
@@ -0,0 +1,10 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ ## [2.0.0] - 2026-02-28
6
+ - Complete rewrite for Ares 2.0.
7
+ - Introduced Deterministic Multi-Agent Orchestration.
8
+ - Added Tiny Task Layer for diagnostic parsing.
9
+ - Integrated TTY toolkit for advanced CLI UX.
10
+ - Gemified project for distribution.
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 shubhamtaywade82@gmail.com
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,110 @@
1
+ # 🧠 Ares 2.0: Agent Orchestrator
2
+
3
+ A production-grade, deterministic multi-agent orchestrator CLI. Ares routes tasks to the best localized executor (Claude, Codex, or Cursor) based on a strategic planning phase powered by local Ollama.
4
+
5
+ ## 🏗️ Architecture
6
+
7
+ ```mermaid
8
+ graph TD
9
+ User["User Task"] --> Router["Router (Ares CLI)"]
10
+ Router --> Planner["Ollama Planner (Local qwen3:latest)"]
11
+ Planner --> Router
12
+ Router --> TinyTask["Tiny Task Layer (Diagnostics)"]
13
+ TinyTask --> Router
14
+ Router --> Selector["Model Selector (Config-Driven)"]
15
+ Selector --> Router
16
+ Router --> Quota["Quota Manager"]
17
+ Router --> Logger["Task Logger (UUID)"]
18
+ Router --> Git["Git Manager (Auto-branch)"]
19
+ Router --> Adapter["Engine Adapter (Claude/Codex/Cursor)"]
20
+ Adapter --> Router
21
+ Router --> Verify["Post-Fix Verification"]
22
+ ```
23
+
24
+ - **Planning Layer**: Uses local Ollama model to classify tasks, assign risk/confidence scores, and decompose tasks into discrete slices.
25
+ - **Tiny Task Layer (Diagnostics)**: Offloads raw terminal output parsing and diff summarization to local Ollama, reducing token load on Claude by 60-80%.
26
+ - **Routing Layer**: Deterministic rules in `config/ares/models.yml` allocate tasks to engines based on type and risk.
27
+ - **Automated Fix Loop**: Detects failures, summarizes them locally, escalates for a fix, and re-verifies automatically.
28
+ - **Traceability**: Every task receives a UUID and is logged in `logs/UUID.json`.
29
+ - **Safety**: Built-in quota tracking and confidence-based escalation to Claude Opus for high-risk work.
30
+
31
+ ## 📟 Terminal User Interface (TUI)
32
+
33
+ Ares 2.0 includes a professional, interactive dashboard for real-time task management.
34
+ ```bash
35
+ bin/ares --tui
36
+ ```
37
+ - **Live Monitoring**: Track quotas, task history, and AI reasoning live.
38
+ - **In-App Config**: Change models and Ollama settings without leaving the CLI.
39
+ - **Universal Fixes**: Trigger test, lint, or syntax healing with one keypress.
40
+
41
+ ## 📚 Documentation & Guides
42
+
43
+ - [**TUI Guide**](file:///home/nemesis/project/agent-orchestrator/docs/TUI_GUIDE.md): Master the interactive dashboard.
44
+ - [**Self-Healing Guide**](file:///home/nemesis/project/agent-orchestrator/docs/SELF_HEALING.md): How Ares automatically repairs your code.
45
+ - [**Configuration Guide**](file:///home/nemesis/project/agent-orchestrator/docs/CONFIGURATION.md): Tuning model routing and local AI parameters.
46
+
47
+ ## 🚀 Usage
48
+
49
+ ### Installation (from RubyGems)
50
+ ```bash
51
+ gem install ares-runtime
52
+ ```
53
+
54
+ ### Development Setup
55
+ Install dependencies:
56
+ ```bash
57
+ bundle install
58
+ ```
59
+
60
+ Run a task:
61
+ ```bash
62
+ # Using the installed gem
63
+ ares "Task description"
64
+
65
+ # Or using the local executable
66
+ exe/ares "Task description"
67
+ ```
68
+
69
+ ### Automated Diagnostic Loop
70
+ To run tests and automatically attempt fixes for failures:
71
+ ```bash
72
+ exe/ares "run tests"
73
+ ```
74
+
75
+ ### Flags
76
+ - `-d, --dry-run`: Plan and select model without execution.
77
+ - `-g, --git`: Auto-branch before execution and auto-commit results.
78
+
79
+ ## 🎯 Model Routing Rules
80
+
81
+ Routed via `config/models.yml`:
82
+ - **Architecture**: Claude Opus (High Reasoning)
83
+ - **Refactor**: Claude Sonnet (Primary Executor)
84
+ - **Bulk Patch / Test Gen**: Codex (High Speed)
85
+ - **Interactive Edit**: Cursor Agent (Human-in-the-loop)
86
+ - **Summarization**: Claude Haiku (Low Cost)
87
+
88
+ ## 🔐 Safety & Project Hygiene
89
+
90
+ 1. **Deterministic One-Hop**: No recursive agent loops.
91
+ 2. **Quota Aware**: Claude usage is tracked daily via `QuotaManager`.
92
+ 3. **Workspace Isolation**: Execution is pinned to the current directory's context via recursive `AGENTS.md` discovery.
93
+ 4. **Git Protection**: `.gitignore` automatically excludes `logs/`, `*.gem`, and AI-specific session/cache directories (`.claude`, `.cursor`, etc.).
94
+
95
+ ## 🛠️ Configuration
96
+
97
+ - `config/models.yml`: Define routing logic and confidence thresholds.
98
+ - `config/workspaces.yml`: Register explicit workspace roots.
99
+ - `config/planner_schema.rb`: The strict JSON schema for the Ollama planner.
100
+
101
+ ## 🛤️ Roadmap
102
+
103
+ - [ ] **Cost Tracker**: USD/token cost calculation per engine.
104
+ - [ ] **Parallel Execution**: Execute independent task slices concurrently.
105
+ - [ ] **Automatic Diff Chunking**: Handle massive diffs by chunking before LLM processing.
106
+ - [ ] **Rate Limiting**: Intelligent throttling to prevent mid-workflow blocks.
107
+
108
+ ---
109
+ **Author**: Antigravity (shubhamtaywade82@gmail.com)
110
+ **Repository**: [github.com/shubhamtaywade82/agent-orchestrator](https://github.com/shubhamtaywade82/agent-orchestrator)
data/config/models.yml ADDED
@@ -0,0 +1,25 @@
1
+ architecture:
2
+ engine: claude
3
+ model: opus
4
+
5
+ refactor:
6
+ engine: claude
7
+ model: sonnet
8
+
9
+ bulk_patch:
10
+ engine: codex
11
+ model: default
12
+
13
+ test_generation:
14
+ engine: codex
15
+ model: default
16
+
17
+ summarization:
18
+ engine: ollama
19
+ model: default
20
+
21
+ interactive_edit:
22
+ engine: cursor
23
+
24
+ pr_automation:
25
+ engine: codex
data/config/ollama.yml ADDED
@@ -0,0 +1,4 @@
1
+ base_url: "http://localhost:11434"
2
+ timeout: 300
3
+ num_ctx: 4096
4
+ retries: 3
@@ -0,0 +1,6 @@
1
+ # Register your project roots here for context discovery
2
+ # Example:
3
+ # workspaces:
4
+ # - /path/to/project-a
5
+ # - /path/to/project-b
6
+ workspaces: []
data/exe/ares ADDED
@@ -0,0 +1,75 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require 'ares_runtime'
5
+ require 'optparse'
6
+
7
+ module Ares
8
+ class CLI
9
+ def self.start
10
+ command = ARGV.shift
11
+
12
+ case command
13
+ when 'init' then init
14
+ when 'config' then config
15
+ when 'doctor' then doctor
16
+ when 'version' then version
17
+ when 'logs' then logs
18
+ else
19
+ run_task(command)
20
+ end
21
+ end
22
+
23
+ def self.init
24
+ Ares::Runtime::Initializer.run
25
+ end
26
+
27
+ def self.config
28
+ Ares::Runtime::ConfigCLI.run
29
+ end
30
+
31
+ def self.doctor
32
+ Ares::Runtime::Doctor.run
33
+ end
34
+
35
+ def self.version
36
+ puts "Ares v#{Ares::Runtime::VERSION}"
37
+ end
38
+
39
+ def self.logs
40
+ Ares::Runtime::LogsCLI.run
41
+ end
42
+
43
+ def self.run_task(first_arg)
44
+ options = {
45
+ dry_run: false,
46
+ git: false,
47
+ tui: false
48
+ }
49
+
50
+ OptionParser.new do |opts|
51
+ opts.banner = 'Usage: ares [options] "task description"'
52
+
53
+ opts.on('-d', '--dry-run') { options[:dry_run] = true }
54
+ opts.on('-g', '--git') { options[:git] = true }
55
+ opts.on('--tui') { options[:tui] = true }
56
+ end.parse!
57
+
58
+ if options[:tui]
59
+ Ares::Runtime::Tui.start
60
+ exit
61
+ end
62
+
63
+ task = ([first_arg] + ARGV).compact.join(' ').strip
64
+
65
+ if task.empty?
66
+ puts 'No task provided.'
67
+ exit 1
68
+ end
69
+
70
+ Ares::Runtime::Router.new.run(task, options)
71
+ end
72
+ end
73
+ end
74
+
75
+ Ares::CLI.start
data/lib/ares/cli.rb ADDED
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'runtime/config_manager'
4
+ require 'fileutils'
5
+
6
+ module Ares
7
+ class CLI
8
+ def self.init
9
+ root = Ares::Runtime::ConfigManager.project_root
10
+ target_dir = File.join(root, 'config', 'ares')
11
+
12
+ if Dir.exist?(target_dir)
13
+ puts "Ares config already exists at #{target_dir}"
14
+ return
15
+ end
16
+
17
+ FileUtils.mkdir_p(target_dir)
18
+
19
+ copy_default('models.yml', target_dir)
20
+ copy_default('ollama.yml', target_dir)
21
+
22
+ puts "Ares initialized at #{target_dir}"
23
+ end
24
+
25
+ def self.copy_default(filename, target_dir)
26
+ gem_root = Gem::Specification.find_by_name('agent-orchestrator').gem_dir
27
+ source = File.join(gem_root, 'config', filename)
28
+
29
+ unless File.exist?(source)
30
+ puts "Warning: default #{filename} not found in gem."
31
+ return
32
+ end
33
+
34
+ FileUtils.cp(source, File.join(target_dir, filename))
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'open3'
4
+ require 'timeout'
5
+
6
+ module Ares
7
+ module Runtime
8
+ # Abstract base class implementing the Template Method pattern for CLI adapters.
9
+ # Defines the skeleton for execution, timeouts, and retries.
10
+ class BaseAdapter
11
+ DEFAULT_TIMEOUT = 30
12
+
13
+ # Template Method: The core algorithm skeleton
14
+ def call(prompt, model = nil, **options)
15
+ cmd = build_command(prompt, model, **options)
16
+
17
+ output, status = execute_with_timeout(cmd, prompt, timeout_seconds)
18
+
19
+ if should_retry?(status, output)
20
+ cmd = build_retry_command(cmd, prompt, **options)
21
+ output, status = execute_with_timeout(cmd, prompt, timeout_seconds)
22
+ end
23
+
24
+ handle_errors(status, output)
25
+
26
+ output
27
+ end
28
+
29
+ protected
30
+
31
+ def execute_with_timeout(cmd, prompt, timeout)
32
+ Timeout.timeout(timeout) do
33
+ Open3.capture2e(*cmd, stdin_data: prompt)
34
+ end
35
+ rescue Timeout::Error => e
36
+ raise "#{adapter_name} timed out after #{timeout}s: #{e.message}"
37
+ end
38
+
39
+ def handle_errors(status, output)
40
+ raise "#{adapter_name} command failed: #{output}" unless status.success?
41
+ end
42
+
43
+ # Subclasses MUST implement this
44
+ def build_command(prompt, model, **options)
45
+ raise NotImplementedError, "#{self.class} must implement #build_command"
46
+ end
47
+
48
+ # Hook: Override in subclasses for complex retry logic
49
+ def should_retry?(_status, _output)
50
+ false
51
+ end
52
+
53
+ # Hook: Customize the command for the retry attempt
54
+ def build_retry_command(cmd, _prompt, **_options)
55
+ cmd
56
+ end
57
+
58
+ # Hook: Override for specific adapter timeouts
59
+ def timeout_seconds
60
+ DEFAULT_TIMEOUT
61
+ end
62
+
63
+ def adapter_name
64
+ self.class.name.split('::').last.sub('Adapter', '')
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base_adapter'
4
+
5
+ module Ares
6
+ module Runtime
7
+ # Adapter for Claude CLI. Passes prompts via stdin to avoid ARG_MAX limits.
8
+ class ClaudeAdapter < BaseAdapter
9
+ def call(prompt, model = nil, fork_session: false, **_options)
10
+ check_auth!
11
+ super(prompt, model, fork_session: fork_session)
12
+ end
13
+
14
+ protected
15
+
16
+ def build_command(_prompt, model, fork_session: false, **_options)
17
+ model ||= 'sonnet'
18
+ # -p - means read prompt from stdin
19
+ # --allow-dangerously-skip-permissions bypasses interactive prompts
20
+ cmd = ['claude', '--model', model, '-p', '-', '--allow-dangerously-skip-permissions']
21
+ cmd += %w[--continue --fork-session] if fork_session
22
+ cmd
23
+ end
24
+
25
+ private
26
+
27
+ def check_auth!
28
+ system('claude auth status > /dev/null 2>&1')
29
+ return if $CHILD_STATUS.success?
30
+
31
+ raise 'Claude CLI not logged in. Please run `claude login` in your terminal.'
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base_adapter'
4
+
5
+ module Ares
6
+ module Runtime
7
+ # Adapter for OpenAI Codex CLI. Uses exec mode with full automation for headless environments.
8
+ class CodexAdapter < BaseAdapter
9
+ def call(prompt, model = nil, resume: true, **_options)
10
+ super(prompt, model, resume: resume)
11
+ end
12
+
13
+ def apply_cloud_task(task_id)
14
+ cmd = ['codex', 'apply', task_id]
15
+ Ares::Runtime::TerminalRunner.run(cmd)
16
+ end
17
+
18
+ protected
19
+
20
+ def build_command(_prompt, _model, resume: true, **_options)
21
+ cmd = ['codex', 'exec', '--full-auto', '-']
22
+ cmd << '--resume' if resume
23
+ cmd
24
+ end
25
+
26
+ def should_retry?(status, output)
27
+ !status.success? && (output.include?('No session found') || output.include?('error: unexpected argument'))
28
+ end
29
+
30
+ def build_retry_command(cmd, _prompt, **_options)
31
+ cmd.dup.tap { |c| c.delete('--resume') }
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base_adapter'
4
+
5
+ module Ares
6
+ module Runtime
7
+ # Adapter for Cursor CLI (agent). Uses stdin piping and trust flags for automation.
8
+ class CursorAdapter < BaseAdapter
9
+ def call(prompt, model = nil, resume: true, cloud: false, **_options)
10
+ super(prompt, model, resume: resume, cloud: cloud)
11
+ end
12
+
13
+ protected
14
+
15
+ def build_command(_prompt, _model, resume: true, cloud: false, **_options)
16
+ # --trust --yolo ensures no interactive prompts in headless mode
17
+ cmd = ['agent', '-p', '-', '--trust', '--yolo']
18
+ cmd << '-c' if cloud
19
+ cmd << '--continue' if resume && !cloud
20
+ cmd
21
+ end
22
+
23
+ def should_retry?(status, output)
24
+ !status.success? && output.include?('No previous chats found')
25
+ end
26
+
27
+ def build_retry_command(cmd, _prompt, **_options)
28
+ cmd.dup.tap { |c| c.delete('--continue') }
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ares
4
+ module Runtime
5
+ class OllamaAdapter
6
+ def initialize
7
+ config_data = ConfigManager.load_ollama
8
+ config = Ollama::Config.new
9
+ config.base_url = config_data[:base_url]
10
+ config.timeout = config_data[:timeout]
11
+ config.num_ctx = config_data[:num_ctx]
12
+ config.retries = config_data[:retries]
13
+
14
+ @client = Ollama::Client.new(config: config)
15
+ end
16
+
17
+ def call(prompt, model = nil, schema: nil)
18
+ model ||= best_available_model
19
+
20
+ options = { prompt: prompt, model: model }
21
+ options[:schema] = schema if schema
22
+
23
+ @client.generate(**options)
24
+ end
25
+
26
+ private
27
+
28
+ def best_available_model
29
+ available = @client.list_model_names
30
+ return 'qwen3:latest' if available.include?('qwen3:latest')
31
+ return 'qwen3:8b' if available.include?('qwen3:8b')
32
+
33
+ available.first || 'qwen3:8b' # Fallback
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ares
4
+ module Runtime
5
+ class ConfigCLI
6
+ def self.run
7
+ models = ConfigManager.load_models
8
+ ollama = ConfigManager.load_ollama
9
+
10
+ puts "\nModels Configuration:"
11
+ puts models.to_yaml
12
+
13
+ puts "\nOllama Configuration:"
14
+ puts ollama.to_yaml
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,137 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'yaml'
4
+ require 'fileutils'
5
+
6
+ module Ares
7
+ module Runtime
8
+ class ConfigManager
9
+ GLOBAL_DIR = File.expand_path('~/.ares')
10
+
11
+ # Public API -----------------------------------------------------------
12
+ def self.load_models
13
+ load_merged('models.yml')
14
+ end
15
+
16
+ def self.load_ollama
17
+ load_merged('ollama.yml')
18
+ end
19
+
20
+ def self.save_models(config)
21
+ save_config('models.yml', config)
22
+ end
23
+
24
+ def self.save_ollama(config)
25
+ save_config('ollama.yml', config)
26
+ end
27
+
28
+ def self.task_types
29
+ load_models.keys
30
+ end
31
+
32
+ def self.update_task_config(task_type, engine, model = nil)
33
+ config = load_models
34
+ config[task_type] = { engine: engine, model: model }
35
+ save_models(config)
36
+ end
37
+
38
+ # -------------------------------------------------------------------
39
+ # Internal helpers
40
+ def self.load_merged(filename)
41
+ merged = {}
42
+ merged = deep_merge(merged, load_file(gem_default_path(filename)))
43
+ merged = deep_merge(merged, load_file(global_path(filename)))
44
+ deep_merge(merged, load_file(local_path(filename)))
45
+ end
46
+
47
+ def self.load_file(path)
48
+ return {} unless File.exist?(path)
49
+
50
+ data = YAML.load_file(path)
51
+ return {} unless data.is_a?(Hash)
52
+
53
+ symbolize_keys(data)
54
+ rescue StandardError
55
+ {}
56
+ end
57
+
58
+ def self.save_config(filename, config)
59
+ target = File.exist?(local_path(filename)) ? local_path(filename) : global_path(filename)
60
+ FileUtils.mkdir_p(File.dirname(target))
61
+ File.write(target, stringify_keys(config || {}).to_yaml)
62
+ end
63
+
64
+ # Path helpers --------------------------------------------------------
65
+ def self.project_root
66
+ dir = Dir.pwd
67
+
68
+ loop do
69
+ # Look for the namespaced config directory
70
+ return dir if File.exist?(File.join(dir, 'config', 'ares', 'models.yml'))
71
+
72
+ parent = File.dirname(dir)
73
+ break if parent == dir
74
+
75
+ dir = parent
76
+ end
77
+
78
+ Dir.pwd
79
+ end
80
+
81
+ def self.local_path(filename)
82
+ File.join(project_root, 'config', 'ares', filename)
83
+ end
84
+
85
+ def self.global_path(filename)
86
+ File.join(GLOBAL_DIR, filename)
87
+ end
88
+
89
+ def self.gem_default_path(filename)
90
+ spec = Gem.loaded_specs['ares-runtime'] || begin
91
+ Gem::Specification.find_by_name('ares-runtime')
92
+ rescue StandardError
93
+ nil
94
+ end
95
+ if spec
96
+ File.join(spec.gem_dir, 'config', filename)
97
+ else
98
+ File.expand_path("../../config/#{filename}", __dir__)
99
+ end
100
+ end
101
+
102
+ # Utility methods ------------------------------------------------------
103
+ def self.deep_merge(hash1, hash2)
104
+ result = hash1.dup
105
+ hash2.each do |key, value|
106
+ result[key] = if result[key].is_a?(Hash) && value.is_a?(Hash)
107
+ deep_merge(result[key], value)
108
+ else
109
+ value
110
+ end
111
+ end
112
+ result
113
+ end
114
+
115
+ def self.symbolize_keys(hash)
116
+ return {} unless hash.is_a?(Hash)
117
+
118
+ hash.each_with_object({}) do |(k, v), h|
119
+ key = begin
120
+ k.to_sym
121
+ rescue StandardError
122
+ k
123
+ end
124
+ h[key] = v.is_a?(Hash) ? symbolize_keys(v) : v
125
+ end
126
+ end
127
+
128
+ def self.stringify_keys(hash)
129
+ return {} unless hash.is_a?(Hash)
130
+
131
+ hash.transform_keys(&:to_s).each_with_object({}) do |(k, v), h|
132
+ h[k] = v.is_a?(Hash) ? stringify_keys(v) : v
133
+ end
134
+ end
135
+ end
136
+ end
137
+ end