Skip to content

Commit 2247a1c

Browse files
committed
XDG migration
1 parent 1cdfd8c commit 2247a1c

22 files changed

+1174
-138
lines changed

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -186,7 +186,7 @@ When creating GitHub issues, always check available labels, projects, and milest
186186

187187
```bash
188188
# List all available labels
189-
gh api repos/Shopify/roast/labels | jq '.[].name'
189+
gh label list --repo Shopify/roast --json name --jq '.[].name'
190190
191191
# List all milestones
192192
gh api repos/Shopify/roast/milestones | jq '.[] | {title: .title, number: .number, state: .state}'

README.md

Lines changed: 36 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -490,8 +490,8 @@ This feature is particularly useful when:
490490
- Resuming after failures in long-running workflows
491491

492492
**Storage Locations:**
493-
- SQLite: `~/.roast/sessions.db` (configurable via `ROAST_SESSIONS_DB`)
494-
- Filesystem: `.roast/sessions/` directory in your project
493+
- SQLite: `$XDG_DATA_HOME/roast/sessions.db` (default: `~/.local/share/roast/sessions.db`, configurable via `ROAST_SESSIONS_DB`)
494+
- Filesystem: `$XDG_DATA_HOME/roast/sessions/` (default: `~/.local/share/roast/sessions/`)
495495

496496
#### Target Option (`-t, --target`)
497497

@@ -1030,10 +1030,11 @@ See the [MCP tools example](examples/mcp/) for complete documentation and more e
10301030

10311031
### Custom Tools
10321032

1033-
You can create your own tools using the [Raix function dispatch pattern](https://github.com/OlympiaAI/raix-rails?tab=readme-ov-file#use-of-toolsfunctions). Custom tools should be placed in `.roast/initializers/` (subdirectories are supported):
1033+
You can create your own tools using the [Raix function dispatch pattern](https://github.com/OlympiaAI/raix-rails?tab=readme-ov-file#use-of-toolsfunctions). Custom tools should be placed in initializers directories (subdirectories are supported):
10341034

10351035
```ruby
1036-
# .roast/initializers/tools/git_analyzer.rb
1036+
# ~/.config/roast/initializers/tools/git_analyzer.rb
1037+
# OR {workflow_directory}/initializers/tools/git_analyzer.rb
10371038
module MyProject
10381039
module Tools
10391040
module GitAnalyzer
@@ -1081,22 +1082,45 @@ The tool will be available to the AI model during workflow execution, and it can
10811082

10821083
### Project-specific Configuration
10831084

1084-
You can extend Roast with project-specific configuration by creating initializers in `.roast/initializers/`. These are automatically loaded when workflows run, allowing you to:
1085+
You can extend Roast with project-specific configuration by creating initializers. These are automatically loaded when workflows run, allowing you to:
10851086

10861087
- Add custom instrumentation
10871088
- Configure monitoring and metrics
10881089
- Set up project-specific tools
10891090
- Customize workflow behavior
10901091

1091-
Example structure:
1092+
Roast supports initializers in multiple locations (in priority order):
1093+
1094+
1. **Workflow-local initializers**: Place alongside your workflow steps
1095+
2. **Global XDG config**: Shared across all projects using XDG Base Directory specification
1096+
1097+
Example structures:
10921098
```
1093-
your-project/
1094-
├── .roast/
1095-
│ └── initializers/
1096-
│ ├── metrics.rb
1097-
│ ├── logging.rb
1098-
│ └── custom_tools.rb
1099+
# Workflow-local (highest priority)
1100+
your-workflow/
1101+
├── workflow.yml
1102+
├── analyze_code/
1103+
├── initializers/
1104+
│ ├── metrics.rb
1105+
│ ├── logging.rb
1106+
│ └── custom_tools.rb
10991107
└── ...
1108+
1109+
# Global XDG config (user-wide)
1110+
~/.config/roast/
1111+
└── initializers/
1112+
├── shopify_defaults.rb
1113+
└── custom_tools.rb
1114+
```
1115+
1116+
**XDG Configuration Directories:**
1117+
- Config: `$XDG_CONFIG_HOME/roast` (default: `~/.config/roast`)
1118+
- Cache: `$XDG_CACHE_HOME/roast` (default: `~/.cache/roast`)
1119+
1120+
You can customize these locations by setting XDG environment variables:
1121+
```bash
1122+
export XDG_CONFIG_HOME="/custom/config"
1123+
export XDG_CACHE_HOME="/fast/ssd/cache"
11001124
```
11011125

11021126
### Pre/Post Processing Framework

exe/roast

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,4 +14,7 @@ require "bundler/setup"
1414
require "roast"
1515

1616
puts "🔥🔥🔥 Everyone loves a good roast 🔥🔥🔥\n\n"
17+
18+
Roast::XDGMigration.warn_if_migration_needed
19+
1720
Roast::CLI.start(ARGV)

lib/roast.rb

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,11 +39,29 @@
3939

4040
# Set up Zeitwerk autoloader
4141
loader = Zeitwerk::Loader.for_gem
42+
43+
# Configure custom inflector for XDG acronym
44+
loader.inflector.inflect("xdg_migration" => "XDGMigration")
45+
4246
loader.setup
4347

4448
module Roast
4549
ROOT = File.expand_path("../..", __FILE__)
4650

51+
# https://specifications.freedesktop.org/basedir-spec/latest/
52+
XDG_CONFIG_HOME = ENV.fetch("XDG_CONFIG_HOME", File.join(Dir.home, ".config"))
53+
XDG_CACHE_HOME = ENV.fetch("XDG_CACHE_HOME", File.join(Dir.home, ".cache"))
54+
XDG_DATA_HOME = ENV.fetch("XDG_DATA_HOME", File.join(Dir.home, ".local", "share"))
55+
56+
CONFIG_DIR = File.join(XDG_CONFIG_HOME, "roast")
57+
CACHE_DIR = File.join(XDG_CACHE_HOME, "roast")
58+
DATA_DIR = File.join(XDG_DATA_HOME, "roast")
59+
60+
GLOBAL_INITIALIZERS_DIR = File.join(CONFIG_DIR, "initializers")
61+
FUNCTION_CACHE_DIR = File.join(CACHE_DIR, "function_calls")
62+
SESSION_DATA_DIR = File.join(DATA_DIR, "sessions")
63+
SESSION_DB_PATH = ENV.fetch("ROAST_SESSIONS_DB", File.join(DATA_DIR, "sessions.db"))
64+
4765
class CLI < Thor
4866
desc "execute [WORKFLOW_CONFIGURATION_FILE] [FILES...]", "Run a configured workflow"
4967
option :concise, type: :boolean, aliases: "-c", desc: "Optional flag for use in output templates"
@@ -65,6 +83,9 @@ def execute(*paths)
6583
File.expand_path("roast/#{workflow_path}/workflow.yml")
6684
end
6785

86+
context_path = File.dirname(expanded_workflow_path)
87+
Roast::XDGMigration.warn_if_migration_needed(context_path)
88+
6889
raise Thor::Error, "Expected a Roast workflow configuration file, got directory: #{expanded_workflow_path}" if File.directory?(expanded_workflow_path)
6990

7091
Roast::Workflow::ConfigurationParser.new(expanded_workflow_path, files, options.transform_keys(&:to_sym)).begin!
@@ -81,6 +102,9 @@ def resume(workflow_path)
81102
File.expand_path("roast/#{workflow_path}/workflow.yml")
82103
end
83104

105+
context_path = File.dirname(expanded_workflow_path)
106+
Roast::XDGMigration.warn_if_migration_needed(context_path)
107+
84108
unless File.exist?(expanded_workflow_path)
85109
raise Thor::Error, "Workflow file not found: #{expanded_workflow_path}"
86110
end
@@ -140,6 +164,8 @@ def list
140164
puts "Available workflows:"
141165
puts
142166

167+
Roast::XDGMigration.warn_if_migration_needed
168+
143169
workflow_files.each do |file|
144170
workflow_name = File.dirname(file.sub("#{roast_dir}/", ""))
145171
puts " #{workflow_name} (from project)"
@@ -152,6 +178,9 @@ def list
152178
desc "validate [WORKFLOW_CONFIGURATION_FILE]", "Validate a workflow configuration"
153179
option :strict, type: :boolean, aliases: "-s", desc: "Treat warnings as errors"
154180
def validate(workflow_path = nil)
181+
context_path = workflow_path ? File.dirname(workflow_path) : nil
182+
Roast::XDGMigration.warn_if_migration_needed(context_path)
183+
155184
validation_command = Roast::Workflow::ValidationCommand.new(options)
156185
validation_command.execute(workflow_path)
157186
end
@@ -203,6 +232,8 @@ def sessions
203232

204233
desc "session SESSION_ID", "Show details for a specific session"
205234
def session(session_id)
235+
Roast::XDGMigration.warn_if_migration_needed
236+
206237
repository = Workflow::StateRepositoryFactory.create
207238

208239
unless repository.respond_to?(:get_session_details)
@@ -253,6 +284,9 @@ def session(session_id)
253284
desc "diagram WORKFLOW_FILE", "Generate a visual diagram of a workflow"
254285
option :output, type: :string, aliases: "-o", desc: "Output file path (defaults to workflow_name_diagram.png)"
255286
def diagram(workflow_file)
287+
context_path = File.dirname(workflow_file)
288+
Roast::XDGMigration.warn_if_migration_needed(context_path)
289+
256290
unless File.exist?(workflow_file)
257291
raise Thor::Error, "Workflow file not found: #{workflow_file}"
258292
end
@@ -266,6 +300,13 @@ def diagram(workflow_file)
266300
raise Thor::Error, "Error generating diagram: #{e.message}"
267301
end
268302

303+
desc "xdg-migrate", "Migrate legacy .roast directories to XDG directories"
304+
option :auto_confirm, type: :boolean, aliases: "-a", desc: "Automatically confirm all prompts"
305+
option :workflow_context, type: :string, aliases: "-w", desc: "Workflow context path"
306+
def xdg_migrate
307+
Roast::XDGMigration.migrate(options[:workflow_context], options[:auto_confirm])
308+
end
309+
269310
private
270311

271312
def show_example_picker

lib/roast/helpers/function_caching_interceptor.rb

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,11 @@ def dispatch_tool_function(function_name, params)
1414
})
1515

1616
# Handle workflows with or without configuration
17-
result = if !respond_to?(:configuration) || configuration.nil?
17+
result = if !respond_to?(:workflow_configuration) || workflow_configuration.nil?
1818
super(function_name, params)
1919
else
20-
function_config = if configuration.respond_to?(:function_config)
21-
configuration.function_config(function_name)
20+
function_config = if workflow_configuration.respond_to?(:function_config)
21+
workflow_configuration.function_config(function_name)
2222
else
2323
{}
2424
end

lib/roast/helpers/logger.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ def create_logger(stdout)
5151
msg_string = format_message(msg)
5252

5353
if severity == "INFO" && !msg_string.start_with?("[")
54-
msg_string
54+
msg_string + "\n"
5555
else
5656
"[#{datetime.strftime("%Y-%m-%d %H:%M:%S")}] #{severity}: #{msg_string.gsub(/^\[|\]$/, "").strip}\n"
5757
end

lib/roast/initializers.rb

Lines changed: 36 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -3,36 +3,48 @@
33
module Roast
44
class Initializers
55
class << self
6-
def config_root(starting_path = Dir.pwd, ending_path = File.dirname(Dir.home))
7-
paths = []
8-
candidate = starting_path
9-
while candidate != ending_path
10-
paths << File.join(candidate, ".roast")
11-
candidate = File.dirname(candidate)
6+
def load_all(workflow_context_path = Dir.pwd)
7+
# .reverse so we load the highest priority files last, letting them override lower priority files
8+
initializer_files(workflow_context_path).reverse.each do |file|
9+
load_initializer(file)
1210
end
13-
14-
first_existing = paths.find { |path| Dir.exist?(path) }
15-
first_existing || paths.first
11+
rescue => e
12+
puts "ERROR: Error loading initializers: #{e.message}"
13+
Roast::Helpers::Logger.error("Error loading initializers: #{e.message}")
14+
# Don't fail the workflow if initializers can't be loaded
1615
end
1716

18-
def initializers_path
19-
File.join(Roast::Initializers.config_root, "initializers")
20-
end
17+
private
2118

22-
def load_all
23-
project_initializers = Roast::Initializers.initializers_path
24-
return unless Dir.exist?(project_initializers)
19+
# Get all possible initializer directories in priority order
20+
def initializer_files(workflow_context_path = Dir.pwd)
21+
initializer_files = []
22+
# 1. Workflow-local initializers (highest priority)
23+
local_dir = local_initializers_dir(workflow_context_path)
24+
if Dir.exist?(local_dir)
25+
initializer_files.concat(Dir.glob(File.join(local_dir, "**/*.rb")))
26+
end
2527

26-
$stderr.puts "Loading project initializers from #{project_initializers}"
27-
pattern = File.join(project_initializers, "**/*.rb")
28-
Dir.glob(pattern, sort: true).each do |file|
29-
$stderr.puts "Loading initializer: #{file}"
30-
require file
28+
# 2. XDG global config initializers
29+
if Dir.exist?(Roast::GLOBAL_INITIALIZERS_DIR)
30+
initializer_files.concat(Dir.glob(File.join(Roast::GLOBAL_INITIALIZERS_DIR, "**/*.rb")))
3131
end
32-
rescue => e
33-
puts "ERROR: Error loading initializers: #{e.message}"
34-
Roast::Helpers::Logger.error("Error loading initializers: #{e.message}")
35-
# Don't fail the workflow if initializers can't be loaded
32+
33+
# 3. Legacy .roast directory support (with deprecation warning)
34+
initializer_files.concat(Roast::XDGMigration.legacy_initializers)
35+
36+
unique_initializer_files = initializer_files.uniq { |file| File.basename(file) }
37+
38+
unique_initializer_files
39+
end
40+
41+
def local_initializers_dir(workflow_context_path)
42+
File.join(workflow_context_path, "initializers")
43+
end
44+
45+
def load_initializer(file)
46+
Roast::Helpers::Logger.info("Loading initializer: #{file}")
47+
require file
3648
end
3749
end
3850
end

lib/roast/tools.rb

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,8 @@ module Roast
44
module Tools
55
extend self
66

7-
# Initialize cache and ensure .gitignore exists
8-
cache_dir = File.join(Dir.pwd, ".roast", "cache")
9-
FileUtils.mkdir_p(cache_dir) unless File.directory?(cache_dir)
10-
11-
# Add .gitignore to cache directory
12-
gitignore_path = File.join(cache_dir, ".gitignore")
13-
File.write(gitignore_path, "*") unless File.exist?(gitignore_path)
14-
15-
CACHE = ActiveSupport::Cache::FileStore.new(cache_dir)
7+
# Initialize cache using XDG cache directory
8+
CACHE = ActiveSupport::Cache::FileStore.new(FUNCTION_CACHE_DIR)
169

1710
def file_to_prompt(file)
1811
<<~PROMPT

lib/roast/workflow/session_manager.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ def workflow_directory(session_name, file_path)
7171
file_id = Digest::MD5.hexdigest(file_path || Dir.pwd)
7272
file_basename = File.basename(file_path || Dir.pwd).parameterize.underscore
7373
human_readable_id = "#{file_basename}_#{file_id[0..7]}"
74-
File.join(Dir.pwd, ".roast", "sessions", workflow_dir_name, human_readable_id)
74+
File.join(SESSION_DATA_DIR, workflow_dir_name, human_readable_id)
7575
end
7676

7777
def find_latest_session_directory(workflow_dir)

lib/roast/workflow/sqlite_state_repository.rb

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,6 @@ module Workflow
77
# SQLite-based implementation of StateRepository
88
# Provides structured, queryable session storage with better performance
99
class SqliteStateRepository < StateRepository
10-
DEFAULT_DB_PATH = File.expand_path("~/.roast/sessions.db")
11-
1210
def initialize(db_path: nil, session_manager: SessionManager.new)
1311
super()
1412

@@ -19,7 +17,17 @@ def initialize(db_path: nil, session_manager: SessionManager.new)
1917
raise LoadError, "SQLite storage requires the 'sqlite3' gem. Please add it to your Gemfile or install it: gem install sqlite3"
2018
end
2119

22-
@db_path = db_path || ENV["ROAST_SESSIONS_DB"] || DEFAULT_DB_PATH
20+
# Priority order
21+
paths = [
22+
db_path,
23+
Roast::SESSION_DB_PATH,
24+
Roast::XDGMigration.legacy_sessions_db_path,
25+
]
26+
27+
# If multiple options exist, prefer the first one that exists
28+
@db_path = paths.find { |path| path && File.exist?(path) }
29+
# If no options exist as paths, use the first one that is not nil
30+
@db_path ||= paths.find { |path| !path.nil? }
2331
@session_manager = session_manager
2432
ensure_database
2533
end

0 commit comments

Comments
 (0)