Skip to content

Commit 9d27291

Browse files
committed
XDG migration
1 parent 50eaa64 commit 9d27291

23 files changed

+1368
-139
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}'

Gemfile.lock

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
PATH
22
remote: .
33
specs:
4-
roast-ai (0.4.2)
4+
roast-ai (0.4.3)
55
activesupport (>= 7.0)
66
cli-kit (~> 5.0)
77
cli-ui (= 2.3.0)

README.md

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

530530
**Storage Locations:**
531-
- SQLite: `~/.roast/sessions.db` (configurable via `ROAST_SESSIONS_DB`)
532-
- Filesystem: `.roast/sessions/` directory in your project
531+
- SQLite: `$XDG_DATA_HOME/roast/sessions.db` (default: `~/.local/share/roast/sessions.db`, configurable via `ROAST_SESSIONS_DB`)
532+
- Filesystem: `$XDG_DATA_HOME/roast/sessions/` (default: `~/.local/share/roast/sessions/`)
533533

534534
#### Target Option (`-t, --target`)
535535

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

10831083
### Custom Tools
10841084

1085-
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):
1085+
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):
10861086

10871087
```ruby
1088-
# .roast/initializers/tools/git_analyzer.rb
1088+
# ~/.config/roast/initializers/tools/git_analyzer.rb
1089+
# OR {workflow_directory}/initializers/tools/git_analyzer.rb
10891090
module MyProject
10901091
module Tools
10911092
module GitAnalyzer
@@ -1133,22 +1134,45 @@ The tool will be available to the AI model during workflow execution, and it can
11331134

11341135
### Project-specific Configuration
11351136

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

11381139
- Add custom instrumentation
11391140
- Configure monitoring and metrics
11401141
- Set up project-specific tools
11411142
- Customize workflow behavior
11421143

1143-
Example structure:
1144+
Roast supports initializers in multiple locations (in priority order):
1145+
1146+
1. **Workflow-local initializers**: Place alongside your workflow steps
1147+
2. **Global XDG config**: Shared across all projects using XDG Base Directory specification
1148+
1149+
Example structures:
11441150
```
1145-
your-project/
1146-
├── .roast/
1147-
│ └── initializers/
1148-
│ ├── metrics.rb
1149-
│ ├── logging.rb
1150-
│ └── custom_tools.rb
1151+
# Workflow-local (highest priority)
1152+
your-workflow/
1153+
├── workflow.yml
1154+
├── analyze_code/
1155+
├── initializers/
1156+
│ ├── metrics.rb
1157+
│ ├── logging.rb
1158+
│ └── custom_tools.rb
11511159
└── ...
1160+
1161+
# Global XDG config (user-wide)
1162+
~/.config/roast/
1163+
└── initializers/
1164+
├── shopify_defaults.rb
1165+
└── custom_tools.rb
1166+
```
1167+
1168+
**XDG Configuration Directories:**
1169+
- Config: `$XDG_CONFIG_HOME/roast` (default: `~/.config/roast`)
1170+
- Cache: `$XDG_CACHE_HOME/roast` (default: `~/.cache/roast`)
1171+
1172+
You can customize these locations by setting XDG environment variables:
1173+
```bash
1174+
export XDG_CONFIG_HOME="/custom/config"
1175+
export XDG_CACHE_HOME="/fast/ssd/cache"
11521176
```
11531177

11541178
### Pre/Post Processing Framework

exe/roast

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

1616
puts "🔥🔥🔥 Everyone loves a good roast 🔥🔥🔥\n\n"
17+
1718
Roast::CLI.start(ARGV)

lib/roast.rb

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,11 +40,29 @@
4040

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

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

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

87+
Roast::XDGMigration.warn_if_migration_needed(expanded_workflow_path)
88+
6989
raise Thor::Error, "Expected a Roast workflow configuration file, got directory: #{expanded_workflow_path}" if File.directory?(expanded_workflow_path)
7090

7191
Roast::Workflow::ConfigurationParser.new(expanded_workflow_path, files, options.transform_keys(&:to_sym)).begin!
@@ -82,6 +102,8 @@ def resume(workflow_path)
82102
File.expand_path("roast/#{workflow_path}/workflow.yml")
83103
end
84104

105+
Roast::XDGMigration.warn_if_migration_needed(expanded_workflow_path)
106+
85107
unless File.exist?(expanded_workflow_path)
86108
raise Thor::Error, "Workflow file not found: #{expanded_workflow_path}"
87109
end
@@ -141,6 +163,8 @@ def list
141163
puts "Available workflows:"
142164
puts
143165

166+
Roast::XDGMigration.warn_if_migration_needed
167+
144168
workflow_files.each do |file|
145169
workflow_name = File.dirname(file.sub("#{roast_dir}/", ""))
146170
puts " #{workflow_name} (from project)"
@@ -153,6 +177,8 @@ def list
153177
desc "validate [WORKFLOW_CONFIGURATION_FILE]", "Validate a workflow configuration"
154178
option :strict, type: :boolean, aliases: "-s", desc: "Treat warnings as errors"
155179
def validate(workflow_path = nil)
180+
Roast::XDGMigration.warn_if_migration_needed(workflow_path)
181+
156182
validation_command = Roast::Workflow::ValidationCommand.new(options)
157183
validation_command.execute(workflow_path)
158184
end
@@ -204,6 +230,8 @@ def sessions
204230

205231
desc "session SESSION_ID", "Show details for a specific session"
206232
def session(session_id)
233+
Roast::XDGMigration.warn_if_migration_needed
234+
207235
repository = Workflow::StateRepositoryFactory.create
208236

209237
unless repository.respond_to?(:get_session_details)
@@ -254,6 +282,8 @@ def session(session_id)
254282
desc "diagram WORKFLOW_FILE", "Generate a visual diagram of a workflow"
255283
option :output, type: :string, aliases: "-o", desc: "Output file path (defaults to workflow_name_diagram.png)"
256284
def diagram(workflow_file)
285+
Roast::XDGMigration.warn_if_migration_needed(workflow_file)
286+
257287
unless File.exist?(workflow_file)
258288
raise Thor::Error, "Workflow file not found: #{workflow_file}"
259289
end
@@ -267,8 +297,55 @@ def diagram(workflow_file)
267297
raise Thor::Error, "Error generating diagram: #{e.message}"
268298
end
269299

300+
desc "xdg-migrate [WORKFLOW_PATH]", "Migrate legacy .roast directories to XDG directories"
301+
option :auto_confirm, type: :boolean, aliases: "-a", desc: "Automatically confirm all prompts"
302+
def xdg_migrate(workflow_path = nil)
303+
workflow_file_path = if workflow_path.nil?
304+
Roast::Helpers::Logger.warn(::CLI::UI.fmt("{{yellow:No workflow path provided, will not be able to migrate initializers.}}"))
305+
nil
306+
elsif workflow_path.include?("workflow.yml")
307+
File.expand_path(workflow_path)
308+
else
309+
File.expand_path("roast/#{workflow_path}/workflow.yml")
310+
end
311+
312+
workflow_context_path = File.dirname(workflow_file_path) if workflow_file_path
313+
314+
roast_dirs = Roast::XDGMigration.find_all_legacy_dot_roast_dirs(workflow_context_path || Dir.pwd)
315+
dot_roast_path = run_roast_dir_picker(roast_dirs) unless roast_dirs.empty?
316+
317+
if dot_roast_path.nil?
318+
Roast::Helpers::Logger.info("No legacy .roast directories found.")
319+
return
320+
end
321+
322+
# Migrate specific .roast directory
323+
migration = Roast::XDGMigration.new(
324+
dot_roast_path:,
325+
workflow_context_path:,
326+
auto_confirm: options[:auto_confirm],
327+
)
328+
329+
migration.migrate
330+
end
331+
270332
private
271333

334+
def run_roast_dir_picker(roast_dirs)
335+
choices = roast_dirs.map { |dir| File.dirname(dir) + "/.roast" }
336+
choices << "Cancel"
337+
338+
selected = ::CLI::UI::Prompt.ask("Select a .roast directory to migrate:") do |handler|
339+
choices.each { |choice| handler.option(choice) { |selection| selection } }
340+
end
341+
342+
return if selected == "Cancel"
343+
344+
# Find the full path for the selected directory
345+
selected_index = choices.index(selected)
346+
roast_dirs[selected_index]
347+
end
348+
272349
def show_example_picker
273350
examples = available_examples
274351

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.new.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)

0 commit comments

Comments
 (0)