sxn 0.2.5 → 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.
@@ -0,0 +1,153 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require "pathname"
5
+
6
+ module Sxn
7
+ module Config
8
+ # Handles loading and saving session templates from .sxn/templates.yml
9
+ #
10
+ # Templates define collections of projects (worktree configurations)
11
+ # that can be applied when creating a session.
12
+ class TemplatesConfig
13
+ TEMPLATES_FILE = "templates.yml"
14
+
15
+ attr_reader :sxn_path, :templates_file_path
16
+
17
+ def initialize(sxn_path)
18
+ @sxn_path = Pathname.new(sxn_path)
19
+ @templates_file_path = @sxn_path / TEMPLATES_FILE
20
+ end
21
+
22
+ # Load templates configuration from file
23
+ # @return [Hash] Templates configuration
24
+ def load
25
+ return default_config unless templates_file_path.exist?
26
+
27
+ content = File.read(templates_file_path)
28
+ config = YAML.safe_load(content, permitted_classes: [], permitted_symbols: [], aliases: false) || {}
29
+ normalize_config(config)
30
+ rescue Psych::SyntaxError => e
31
+ raise ConfigurationError, "Invalid YAML in #{templates_file_path}: #{e.message}"
32
+ rescue StandardError => e
33
+ raise ConfigurationError, "Failed to load templates file #{templates_file_path}: #{e.message}"
34
+ end
35
+
36
+ # Save templates configuration to file
37
+ # @param config [Hash] Templates configuration
38
+ def save(config)
39
+ ensure_directory_exists!
40
+
41
+ # Ensure version is set
42
+ config["version"] ||= 1
43
+ config["templates"] ||= {}
44
+
45
+ File.write(templates_file_path, YAML.dump(stringify_keys(config)))
46
+ end
47
+
48
+ # Check if templates file exists
49
+ # @return [Boolean]
50
+ def exists?
51
+ templates_file_path.exist?
52
+ end
53
+
54
+ # Get a specific template by name
55
+ # @param name [String] Template name
56
+ # @return [Hash, nil] Template configuration or nil if not found
57
+ def get_template(name)
58
+ config = load
59
+ templates = config["templates"] || {}
60
+ template = templates[name]
61
+
62
+ return nil unless template
63
+
64
+ # Normalize project entries to hashes
65
+ normalize_template(name, template)
66
+ end
67
+
68
+ # List all template names
69
+ # @return [Array<String>] Template names
70
+ def list_template_names
71
+ config = load
72
+ (config["templates"] || {}).keys
73
+ end
74
+
75
+ # Add or update a template
76
+ # @param name [String] Template name
77
+ # @param template [Hash] Template configuration
78
+ def set_template(name, template)
79
+ config = load
80
+ config["templates"] ||= {}
81
+ config["templates"][name] = template
82
+ save(config)
83
+ end
84
+
85
+ # Remove a template
86
+ # @param name [String] Template name
87
+ # @return [Boolean] True if template was removed
88
+ def remove_template(name)
89
+ config = load
90
+ templates = config["templates"] || {}
91
+
92
+ return false unless templates.key?(name)
93
+
94
+ templates.delete(name)
95
+ save(config)
96
+ true
97
+ end
98
+
99
+ private
100
+
101
+ def default_config
102
+ {
103
+ "version" => 1,
104
+ "templates" => {}
105
+ }
106
+ end
107
+
108
+ def normalize_config(config)
109
+ config["version"] ||= 1
110
+ config["templates"] ||= {}
111
+ config
112
+ end
113
+
114
+ def normalize_template(name, template)
115
+ {
116
+ "name" => name,
117
+ "description" => template["description"],
118
+ "projects" => normalize_projects(template["projects"] || [])
119
+ }
120
+ end
121
+
122
+ def normalize_projects(projects)
123
+ projects.map do |project|
124
+ if project.is_a?(String)
125
+ { "name" => project }
126
+ elsif project.is_a?(Hash)
127
+ # Ensure name key exists
128
+ project["name"] ||= project[:name]
129
+ stringify_keys(project)
130
+ else
131
+ { "name" => project.to_s }
132
+ end
133
+ end
134
+ end
135
+
136
+ def stringify_keys(hash)
137
+ return hash unless hash.is_a?(Hash)
138
+
139
+ hash.transform_keys(&:to_s).transform_values do |value|
140
+ case value
141
+ when Hash then stringify_keys(value)
142
+ when Array then value.map { |v| v.is_a?(Hash) ? stringify_keys(v) : v }
143
+ else value
144
+ end
145
+ end
146
+ end
147
+
148
+ def ensure_directory_exists!
149
+ FileUtils.mkdir_p(sxn_path) unless sxn_path.exist?
150
+ end
151
+ end
152
+ end
153
+ end
data/lib/sxn/config.rb CHANGED
@@ -3,6 +3,7 @@
3
3
  require_relative "config/config_discovery"
4
4
  require_relative "config/config_cache"
5
5
  require_relative "config/config_validator"
6
+ require_relative "config/templates_config"
6
7
 
7
8
  module Sxn
8
9
  module Config
@@ -15,7 +15,7 @@ module Sxn
15
15
  @config_path = File.join(session_path, FILENAME)
16
16
  end
17
17
 
18
- def create(parent_sxn_path:, default_branch:, session_name:)
18
+ def create(parent_sxn_path:, default_branch:, session_name:, template_id: nil)
19
19
  config = {
20
20
  "version" => 1,
21
21
  "parent_sxn_path" => parent_sxn_path,
@@ -23,6 +23,7 @@ module Sxn
23
23
  "session_name" => session_name,
24
24
  "created_at" => Time.now.iso8601
25
25
  }
26
+ config["template_id"] = template_id if template_id
26
27
  File.write(@config_path, YAML.dump(config))
27
28
  config
28
29
  end
@@ -51,6 +52,10 @@ module Sxn
51
52
  read&.dig("session_name")
52
53
  end
53
54
 
55
+ def template_id
56
+ read&.dig("template_id")
57
+ end
58
+
54
59
  def project_root
55
60
  parent_path = parent_sxn_path
56
61
  return nil unless parent_path
@@ -13,7 +13,7 @@ module Sxn
13
13
  @database = initialize_database
14
14
  end
15
15
 
16
- def create_session(name, description: nil, linear_task: nil, default_branch: nil)
16
+ def create_session(name, description: nil, linear_task: nil, default_branch: nil, template_id: nil)
17
17
  validate_session_name!(name)
18
18
  ensure_sessions_folder_exists!
19
19
 
@@ -31,10 +31,14 @@ module Sxn
31
31
  session_config.create(
32
32
  parent_sxn_path: @config_manager.sxn_folder_path,
33
33
  default_branch: branch,
34
- session_name: name
34
+ session_name: name,
35
+ template_id: template_id
35
36
  )
36
37
 
37
- # Create session record
38
+ # Create session record with template_id in metadata
39
+ metadata = {}
40
+ metadata["template_id"] = template_id if template_id
41
+
38
42
  session_data = {
39
43
  id: session_id,
40
44
  name: name,
@@ -46,7 +50,8 @@ module Sxn
46
50
  linear_task: linear_task,
47
51
  default_branch: branch,
48
52
  projects: [],
49
- worktrees: {}
53
+ worktrees: {},
54
+ metadata: metadata
50
55
  }
51
56
 
52
57
  @database.create_session(session_data)
@@ -244,6 +249,7 @@ module Sxn
244
249
  description: metadata["description"] || db_row[:description],
245
250
  linear_task: metadata["linear_task"] || db_row[:linear_task],
246
251
  default_branch: metadata["default_branch"] || db_row[:default_branch],
252
+ template_id: metadata["template_id"],
247
253
  # Support both metadata and database columns for backward compatibility
248
254
  projects: db_row[:projects] || metadata["projects"] || [],
249
255
  worktrees: db_row[:worktrees] || metadata["worktrees"] || {}
@@ -0,0 +1,187 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../config/templates_config"
4
+
5
+ module Sxn
6
+ module Core
7
+ # Manages session templates - collections of projects that can be
8
+ # applied when creating a session to automatically create multiple worktrees.
9
+ class TemplateManager
10
+ attr_reader :config_manager, :templates_config
11
+
12
+ def initialize(config_manager)
13
+ @config_manager = config_manager
14
+ @templates_config = Config::TemplatesConfig.new(config_manager.sxn_folder_path)
15
+ end
16
+
17
+ # List all available templates
18
+ # @return [Array<Hash>] Array of template info hashes
19
+ def list_templates
20
+ config = templates_config.load
21
+ templates = config["templates"] || {}
22
+
23
+ templates.map do |name, template|
24
+ {
25
+ name: name,
26
+ description: template["description"],
27
+ project_count: (template["projects"] || []).size
28
+ }
29
+ end
30
+ end
31
+
32
+ # Get a specific template by name
33
+ # @param name [String] Template name
34
+ # @return [Hash] Template configuration
35
+ # @raise [SessionTemplateNotFoundError] If template not found
36
+ def get_template(name)
37
+ template = templates_config.get_template(name)
38
+
39
+ unless template
40
+ available = list_template_names
41
+ raise SessionTemplateNotFoundError.new(name, available: available)
42
+ end
43
+
44
+ template
45
+ end
46
+
47
+ # Get list of template names
48
+ # @return [Array<String>] Template names
49
+ def list_template_names
50
+ templates_config.list_template_names
51
+ end
52
+
53
+ # Validate a template before use
54
+ # @param name [String] Template name
55
+ # @raise [SessionTemplateValidationError] If template is invalid
56
+ # @raise [SessionTemplateNotFoundError] If template not found
57
+ def validate_template(name)
58
+ template = get_template(name)
59
+ projects = template["projects"] || []
60
+ errors = []
61
+
62
+ errors << "Template has no projects defined" if projects.empty?
63
+
64
+ # Validate each project exists in config
65
+ projects.each do |project_config|
66
+ project_name = project_config["name"]
67
+ project = config_manager.get_project(project_name)
68
+
69
+ errors << "Project '#{project_name}' not found in configuration" unless project
70
+ end
71
+
72
+ raise SessionTemplateValidationError.new(name, errors.join("; ")) if errors.any?
73
+
74
+ true
75
+ end
76
+
77
+ # Create a new template
78
+ # @param name [String] Template name
79
+ # @param description [String] Template description
80
+ # @param projects [Array<String>] Array of project names
81
+ # @return [Hash] Created template
82
+ def create_template(name, description: nil, projects: [])
83
+ validate_template_name!(name)
84
+
85
+ raise SessionTemplateValidationError.new(name, "Template already exists") if template_exists?(name)
86
+
87
+ # Validate all projects exist
88
+ projects.each do |project_name|
89
+ project = config_manager.get_project(project_name)
90
+ raise SessionTemplateValidationError.new(name, "Project '#{project_name}' not found") unless project
91
+ end
92
+
93
+ template = {
94
+ "description" => description,
95
+ "projects" => projects.map { |p| { "name" => p } }
96
+ }
97
+
98
+ templates_config.set_template(name, template)
99
+
100
+ get_template(name)
101
+ end
102
+
103
+ # Update an existing template
104
+ # @param name [String] Template name
105
+ # @param description [String, nil] New description
106
+ # @param projects [Array<String>, nil] New project list
107
+ # @return [Hash] Updated template
108
+ def update_template(name, description: nil, projects: nil)
109
+ template = get_template(name)
110
+
111
+ # Update description if provided
112
+ template["description"] = description if description
113
+
114
+ # Update projects if provided
115
+ if projects
116
+ # Validate all projects exist
117
+ projects.each do |project_name|
118
+ project = config_manager.get_project(project_name)
119
+ raise SessionTemplateValidationError.new(name, "Project '#{project_name}' not found") unless project
120
+ end
121
+
122
+ template["projects"] = projects.map { |p| { "name" => p } }
123
+ end
124
+
125
+ # Save back to config
126
+ config = templates_config.load
127
+ config["templates"] ||= {}
128
+ config["templates"][name] = {
129
+ "description" => template["description"],
130
+ "projects" => template["projects"]
131
+ }
132
+ templates_config.save(config)
133
+
134
+ get_template(name)
135
+ end
136
+
137
+ # Remove a template
138
+ # @param name [String] Template name
139
+ # @return [Boolean] True if removed
140
+ def remove_template(name)
141
+ # Verify template exists
142
+ get_template(name)
143
+
144
+ templates_config.remove_template(name)
145
+ end
146
+
147
+ # Check if a template exists
148
+ # @param name [String] Template name
149
+ # @return [Boolean]
150
+ def template_exists?(name)
151
+ templates_config.get_template(name) != nil
152
+ end
153
+
154
+ # Get project configurations for a template with branch resolution
155
+ # @param name [String] Template name
156
+ # @param default_branch [String] Default branch to use if not specified per-project
157
+ # @return [Array<Hash>] Array of project configs with resolved branches
158
+ def get_template_projects(name, default_branch:)
159
+ template = get_template(name)
160
+ projects = template["projects"] || []
161
+
162
+ projects.map do |project_config|
163
+ project_name = project_config["name"]
164
+ project = config_manager.get_project(project_name)
165
+
166
+ {
167
+ name: project_name,
168
+ path: project[:path],
169
+ branch: project_config["branch"] || default_branch,
170
+ rules: project_config["rules"]
171
+ }
172
+ end
173
+ end
174
+
175
+ private
176
+
177
+ def validate_template_name!(name)
178
+ return if name.match?(/\A[a-zA-Z0-9_-]+\z/)
179
+
180
+ raise SessionTemplateValidationError.new(
181
+ name,
182
+ "Template name must contain only letters, numbers, hyphens, and underscores"
183
+ )
184
+ end
185
+ end
186
+ end
187
+ end
data/lib/sxn/core.rb CHANGED
@@ -6,6 +6,7 @@ require_relative "core/session_manager"
6
6
  require_relative "core/project_manager"
7
7
  require_relative "core/worktree_manager"
8
8
  require_relative "core/rules_manager"
9
+ require_relative "core/template_manager"
9
10
 
10
11
  module Sxn
11
12
  # Core business logic namespace
data/lib/sxn/errors.rb CHANGED
@@ -68,11 +68,34 @@ module Sxn
68
68
  class ApplicationError < Error; end
69
69
  class RollbackError < Error; end
70
70
 
71
- # Template processing errors
71
+ # Template processing errors (Liquid templates)
72
72
  class TemplateError < Error; end
73
73
  class TemplateNotFoundError < TemplateError; end
74
74
  class TemplateProcessingError < TemplateError; end
75
75
 
76
+ # Session template errors (worktree templates)
77
+ class SessionTemplateError < Error; end
78
+
79
+ class SessionTemplateNotFoundError < SessionTemplateError
80
+ def initialize(name, available: [])
81
+ message = "Session template '#{name}' not found"
82
+ message += ". Available templates: #{available.join(", ")}" if available.any?
83
+ super(message)
84
+ end
85
+ end
86
+
87
+ class SessionTemplateValidationError < SessionTemplateError
88
+ def initialize(name, message)
89
+ super("Invalid session template '#{name}': #{message}")
90
+ end
91
+ end
92
+
93
+ class SessionTemplateApplicationError < SessionTemplateError
94
+ def initialize(template_name, message)
95
+ super("Failed to apply template '#{template_name}': #{message}. Session has been rolled back.")
96
+ end
97
+ end
98
+
76
99
  # Database errors
77
100
  class DatabaseError < Error; end
78
101
  class DatabaseConnectionError < DatabaseError; end
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sxn
4
+ module MCP
5
+ module Prompts
6
+ # Guided new session creation workflow
7
+ class NewSession < ::MCP::Prompt
8
+ prompt_name "new-session"
9
+ description "Guided workflow for creating a new development session"
10
+
11
+ arguments [
12
+ ::MCP::Prompt::Argument.new(
13
+ name: "task_description",
14
+ description: "Brief description of what you're working on",
15
+ required: false
16
+ ),
17
+ ::MCP::Prompt::Argument.new(
18
+ name: "projects",
19
+ description: "Comma-separated list of projects to include",
20
+ required: false
21
+ )
22
+ ]
23
+
24
+ class << self
25
+ def template(args = {}, _server_context: nil)
26
+ # Handle both string and symbol keys
27
+ task_description = args["task_description"] || args[:task_description]
28
+ projects = args["projects"] || args[:projects]
29
+ project_list = projects ? projects.split(",").map(&:strip).join(", ") : "Not specified"
30
+
31
+ <<~PROMPT
32
+ # Create a New Development Session
33
+
34
+ Help me create a new sxn development session.
35
+
36
+ ## Task Information
37
+ - Description: #{task_description || "Not provided"}
38
+ - Requested projects: #{project_list}
39
+
40
+ ## Steps to Complete
41
+
42
+ 1. **Generate session name** based on the task description
43
+ 2. **Create the session** using sxn_sessions_create
44
+ 3. **Add worktrees** for each requested project using sxn_worktrees_add
45
+ 4. **Navigate to the session** using sxn_sessions_swap
46
+
47
+ ## Guidelines
48
+ - Session names should be descriptive but concise (e.g., "user-auth", "api-refactor")
49
+ - Use alphanumeric characters, hyphens, and underscores only
50
+ - Apply project rules automatically when creating worktrees
51
+ PROMPT
52
+ end
53
+ end
54
+ end
55
+
56
+ # Multi-repo setup workflow
57
+ class MultiRepoSetup < ::MCP::Prompt
58
+ prompt_name "multi-repo-setup"
59
+ description "Set up a multi-repository development environment"
60
+
61
+ arguments [
62
+ ::MCP::Prompt::Argument.new(
63
+ name: "feature_name",
64
+ description: "Name of the feature being developed across repos",
65
+ required: true
66
+ ),
67
+ ::MCP::Prompt::Argument.new(
68
+ name: "repos",
69
+ description: "Comma-separated list of repository names to include",
70
+ required: false
71
+ )
72
+ ]
73
+
74
+ class << self
75
+ def template(args = {}, _server_context: nil)
76
+ # Handle both string and symbol keys
77
+ feature_name = args["feature_name"] || args[:feature_name]
78
+ repos = args["repos"] || args[:repos]
79
+ repo_list = repos ? repos.split(",").map(&:strip) : []
80
+
81
+ <<~PROMPT
82
+ # Multi-Repository Development Setup
83
+
84
+ Set up a coordinated development environment for: **#{feature_name}**
85
+
86
+ ## Repositories to Include
87
+ #{repo_list.empty? ? "- (Will use sxn_projects_list to find available projects)" : repo_list.map { |r| "- #{r}" }.join("\n")}
88
+
89
+ ## Setup Process
90
+
91
+ 1. **Check registered projects** with sxn_projects_list
92
+ 2. **Create the session** with sxn_sessions_create
93
+ - Name: #{feature_name.downcase.gsub(/\s+/, "-")}
94
+ 3. **Add worktrees** for each repository with sxn_worktrees_add
95
+ 4. **Apply rules** with sxn_rules_apply for each project
96
+ 5. **Navigate** using sxn_sessions_swap
97
+
98
+ ## Best Practices
99
+ - Use the same branch name across all repos
100
+ - Apply rules to copy environment files
101
+ PROMPT
102
+ end
103
+ end
104
+ end
105
+ end
106
+ end
107
+ end