sxn 0.2.5 → 0.3.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 30ceb6fb94a023b31e9b9c4c4354806a5343b7cefa8f1b43047011f86e3048cf
4
- data.tar.gz: 071b888de74d9e284376acf2ed425a5a33ddf329ac01112bc3fbc65acc8dd363
3
+ metadata.gz: 52c6ed94883c7981bdbc6bc0a2faa9d800dbe81fd4af0ee7af41dda777e95421
4
+ data.tar.gz: 1724ced66383451b1d914285b996a8d2c8bc82643fb08a0eb674a8c0c23a6e06
5
5
  SHA512:
6
- metadata.gz: 93cb3f85f80ccc13ffcc62662fa86bd0e8b7531105ab08dc49f0213744a858bbaf7fff8823c2d5358617704eeb5aae6f2264ba02f20e14c71003fd8e912136d4
7
- data.tar.gz: fc1e076616d354218525d2d8ee6a6454a04dfd5d1db969f7032792c91ec1679605a4d0351573a57cd3b5af64c8d06fdecad1068b51dfd66bc2bea5c92bc52dfc
6
+ metadata.gz: ec072a5436b75710595263f5667828a83d0c5c42aa44f8cde622550762b30fd1ce3b244636bbf57df70b89eeda2c765abbfd04b6b80ff3c4998358e8bad4afe2
7
+ data.tar.gz: d1d7fa0c3acc68746767376f8f201004c74e8930ce8069b69aa4ad080b7338d4f21da7743cfd704e20746e942ddeca497fc7baa7f81c3a4d7c638e3b6a8437eb
data/CHANGELOG.md CHANGED
@@ -5,6 +5,20 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.3.0] - 2025-12-16
9
+
10
+ ### Added
11
+ - Session templates support for creating sessions from predefined configurations
12
+ - `sxn templates list` to view available templates
13
+ - `sxn templates show <name>` to view template details
14
+ - `--template` option for `sxn sessions add` to create sessions from templates
15
+ - TemplateManager for template operations and validation
16
+ - TemplatesConfig for loading templates from `templates.yml`
17
+ - Session template error classes for better error handling
18
+
19
+ ### Fixed
20
+ - Project rules now correctly apply when creating sessions from templates
21
+
8
22
  ## [0.2.5] - 2025-11-30
9
23
 
10
24
  ### Added
@@ -101,6 +115,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
101
115
  - Initial placeholder release
102
116
  - Basic gem structure
103
117
 
118
+ [0.3.0]: https://github.com/idl3/sxn/compare/v0.2.5...v0.3.0
119
+ [0.2.5]: https://github.com/idl3/sxn/compare/v0.2.4...v0.2.5
104
120
  [0.2.4]: https://github.com/idl3/sxn/compare/v0.2.3...v0.2.4
105
121
  [0.2.3]: https://github.com/idl3/sxn/compare/v0.2.1...v0.2.3
106
122
  [0.2.1]: https://github.com/idl3/sxn/compare/v0.2.0...v0.2.1
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- sxn (0.2.5)
4
+ sxn (0.3.0)
5
5
  async (~> 2.0)
6
6
  bcrypt (~> 3.1)
7
7
  dry-configurable (~> 1.0)
data/lib/sxn/CLI.rb CHANGED
@@ -42,6 +42,7 @@ module Sxn
42
42
  option :description, type: :string, aliases: "-d", desc: "Session description"
43
43
  option :linear_task, type: :string, aliases: "-l", desc: "Linear task ID"
44
44
  option :branch, type: :string, aliases: "-b", desc: "Default branch for worktrees"
45
+ option :template, type: :string, aliases: "-t", desc: "Template to use for worktree creation"
45
46
  def add(session_name)
46
47
  cmd = Commands::Sessions.new
47
48
  cmd.options = options
@@ -217,6 +218,13 @@ module Sxn
217
218
  handle_error(e)
218
219
  end
219
220
 
221
+ desc "templates SUBCOMMAND", "Manage session templates"
222
+ def templates(subcommand = nil, *args)
223
+ Commands::Templates.start([subcommand, *args].compact)
224
+ rescue Sxn::Error => e
225
+ handle_error(e)
226
+ end
227
+
220
228
  desc "status", "Show overall sxn status"
221
229
  def status
222
230
  show_status
@@ -19,12 +19,14 @@ module Sxn
19
19
  @session_manager = Sxn::Core::SessionManager.new(@config_manager)
20
20
  @project_manager = Sxn::Core::ProjectManager.new(@config_manager)
21
21
  @worktree_manager = Sxn::Core::WorktreeManager.new(@config_manager, @session_manager)
22
+ @template_manager = Sxn::Core::TemplateManager.new(@config_manager)
22
23
  end
23
24
 
24
25
  desc "add NAME", "Create a new session"
25
26
  option :description, type: :string, aliases: "-d", desc: "Session description"
26
27
  option :linear_task, type: :string, aliases: "-l", desc: "Linear task ID"
27
28
  option :branch, type: :string, aliases: "-b", desc: "Default branch for worktrees"
29
+ option :template, type: :string, aliases: "-t", desc: "Template to use for worktree creation"
28
30
  option :activate, type: :boolean, default: true, desc: "Activate session after creation"
29
31
  option :skip_worktree, type: :boolean, default: false, desc: "Skip worktree creation wizard"
30
32
 
@@ -37,9 +39,29 @@ module Sxn
37
39
  name = @prompt.session_name(existing_sessions: existing_sessions)
38
40
  end
39
41
 
42
+ template_id = options[:template]
43
+
44
+ # Validate template if specified
45
+ if template_id
46
+ begin
47
+ @template_manager.validate_template(template_id)
48
+ rescue Sxn::SessionTemplateNotFoundError, Sxn::SessionTemplateValidationError => e
49
+ @ui.error(e.message)
50
+ exit(1)
51
+ end
52
+ end
53
+
40
54
  # Get default branch - use provided option, or prompt interactively
55
+ # Branch is required when using a template
41
56
  default_branch = options[:branch]
42
- default_branch ||= @prompt.default_branch(session_name: name)
57
+ if default_branch.nil?
58
+ if template_id
59
+ @ui.error("Branch is required when using a template. Use -b/--branch to specify.")
60
+ exit(1)
61
+ else
62
+ default_branch = @prompt.default_branch(session_name: name)
63
+ end
64
+ end
43
65
 
44
66
  begin
45
67
  @ui.progress_start("Creating session '#{name}'")
@@ -48,7 +70,8 @@ module Sxn
48
70
  name,
49
71
  description: options[:description],
50
72
  linear_task: options[:linear_task],
51
- default_branch: default_branch
73
+ default_branch: default_branch,
74
+ template_id: template_id
52
75
  )
53
76
 
54
77
  @ui.progress_done
@@ -60,8 +83,13 @@ module Sxn
60
83
 
61
84
  display_session_info(session)
62
85
 
63
- # Offer to add a worktree unless skipped
64
- offer_worktree_wizard(name) unless options[:skip_worktree]
86
+ # Apply template if specified (with atomic rollback on failure)
87
+ if template_id
88
+ apply_template_to_session(name, template_id, default_branch)
89
+ elsif !options[:skip_worktree]
90
+ # Offer to add a worktree unless skipped or template was used
91
+ offer_worktree_wizard(name)
92
+ end
65
93
  rescue Sxn::Error => e
66
94
  @ui.progress_failed
67
95
  @ui.error(e.message)
@@ -275,6 +303,88 @@ module Sxn
275
303
 
276
304
  private
277
305
 
306
+ def apply_template_to_session(session_name, template_id, default_branch)
307
+ template = @template_manager.get_template(template_id)
308
+ projects = template["projects"] || []
309
+ created_worktrees = []
310
+
311
+ @ui.newline
312
+ @ui.section("Applying Template '#{template_id}'")
313
+ @ui.info("Creating #{projects.size} worktree(s)...")
314
+
315
+ begin
316
+ projects.each_with_index do |project_config, index|
317
+ project_name = project_config["name"]
318
+ # Use project-specific branch override, or fall back to session default
319
+ branch = project_config["branch"] || default_branch
320
+
321
+ @ui.progress_start("Creating worktree #{index + 1}/#{projects.size}: #{project_name}")
322
+
323
+ worktree = @worktree_manager.add_worktree(
324
+ project_name,
325
+ branch,
326
+ session_name: session_name
327
+ )
328
+ created_worktrees << worktree
329
+
330
+ @ui.progress_done
331
+
332
+ # Apply project's default rules first
333
+ apply_project_rules(project_name, session_name)
334
+
335
+ # Apply template-specific rule overrides if defined (in addition to project defaults)
336
+ apply_template_rules(session_name, project_name, worktree[:path], project_config["rules"]) if project_config["rules"]
337
+ end
338
+
339
+ @ui.newline
340
+ @ui.success("Template applied: #{created_worktrees.size} worktree(s) created")
341
+
342
+ # Display created worktrees
343
+ @ui.newline
344
+ @ui.subsection("Created Worktrees")
345
+ created_worktrees.each do |wt|
346
+ @ui.list_item("#{wt[:project]} (#{wt[:branch]}) → #{wt[:path]}")
347
+ end
348
+ rescue StandardError => e
349
+ # ATOMIC ROLLBACK: Remove all created worktrees and the session
350
+ @ui.progress_failed
351
+ @ui.newline
352
+ @ui.warning("Template application failed, rolling back...")
353
+
354
+ rollback_template_application(session_name, created_worktrees)
355
+
356
+ raise Sxn::SessionTemplateApplicationError.new(template_id, e.message)
357
+ end
358
+ end
359
+
360
+ def rollback_template_application(session_name, created_worktrees)
361
+ # Remove created worktrees
362
+ created_worktrees.each do |wt|
363
+ @worktree_manager.remove_worktree(wt[:project], session_name: session_name)
364
+ rescue StandardError
365
+ # Best effort cleanup, continue with rollback
366
+ nil
367
+ end
368
+
369
+ # Remove the session
370
+ @session_manager.remove_session(session_name, force: true)
371
+ @ui.info("Session '#{session_name}' has been rolled back")
372
+ rescue StandardError => e
373
+ @ui.warning("Rollback encountered errors: #{e.message}")
374
+ end
375
+
376
+ def apply_template_rules(_session_name, project_name, worktree_path, rules)
377
+ return unless rules.is_a?(Hash)
378
+
379
+ rules_manager = Sxn::Core::RulesManager.new(@config_manager, worktree_path)
380
+
381
+ rules_manager.apply_copy_files_rules(rules["copy_files"]) if rules["copy_files"]
382
+
383
+ rules_manager.apply_setup_commands(rules["setup_commands"]) if rules["setup_commands"]
384
+ rescue StandardError => e
385
+ @ui.warning("Failed to apply rules for #{project_name}: #{e.message}")
386
+ end
387
+
278
388
  def ensure_initialized!
279
389
  return if @config_manager.initialized?
280
390
 
@@ -0,0 +1,230 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "thor"
4
+
5
+ module Sxn
6
+ module Commands
7
+ # Manage session templates
8
+ class Templates < Thor
9
+ include Thor::Actions
10
+
11
+ def initialize(args = ARGV, local_options = {}, config = {})
12
+ super
13
+ @ui = Sxn::UI::Output.new
14
+ @prompt = Sxn::UI::Prompt.new
15
+ @table = Sxn::UI::Table.new
16
+ @config_manager = Sxn::Core::ConfigManager.new
17
+ @template_manager = Sxn::Core::TemplateManager.new(@config_manager)
18
+ @project_manager = Sxn::Core::ProjectManager.new(@config_manager)
19
+ end
20
+
21
+ desc "list", "List available session templates"
22
+
23
+ def list
24
+ ensure_initialized!
25
+
26
+ templates = @template_manager.list_templates
27
+
28
+ @ui.section("Session Templates")
29
+
30
+ if templates.empty?
31
+ @ui.empty_state("No templates defined")
32
+ @ui.newline
33
+ @ui.recovery_suggestion("Create a template with 'sxn templates create'")
34
+ @ui.recovery_suggestion("Or manually edit .sxn/templates.yml")
35
+ else
36
+ display_templates_table(templates)
37
+ @ui.newline
38
+ @ui.info("Total: #{templates.size} template(s)")
39
+ end
40
+ rescue Sxn::Error => e
41
+ @ui.error(e.message)
42
+ exit(e.exit_code)
43
+ end
44
+
45
+ desc "show NAME", "Show details of a session template"
46
+
47
+ def show(name)
48
+ ensure_initialized!
49
+
50
+ template = @template_manager.get_template(name)
51
+
52
+ @ui.section("Template: #{name}")
53
+ @ui.key_value("Description", template["description"] || "(none)")
54
+ @ui.key_value("Projects", (template["projects"] || []).size.to_s)
55
+
56
+ @ui.newline
57
+ @ui.subsection("Projects")
58
+
59
+ projects = template["projects"] || []
60
+ if projects.empty?
61
+ @ui.empty_state("No projects in this template")
62
+ else
63
+ projects.each do |project_config|
64
+ project_name = project_config["name"]
65
+ project = begin
66
+ @project_manager.get_project(project_name)
67
+ rescue Sxn::ProjectNotFoundError
68
+ nil
69
+ end
70
+ status = project ? "✓" : "✗ (not found)"
71
+
72
+ details = []
73
+ details << "branch: #{project_config["branch"]}" if project_config["branch"]
74
+ details << "has custom rules" if project_config["rules"]
75
+
76
+ if details.any?
77
+ @ui.list_item("#{project_name} #{status} (#{details.join(", ")})")
78
+ else
79
+ @ui.list_item("#{project_name} #{status}")
80
+ end
81
+ end
82
+ end
83
+
84
+ @ui.newline
85
+ @ui.subsection("Usage")
86
+ @ui.command_example(
87
+ "sxn add my-session -t #{name} -b my-branch",
88
+ "Create session with this template"
89
+ )
90
+ rescue Sxn::SessionTemplateNotFoundError => e
91
+ @ui.error(e.message)
92
+ exit(1)
93
+ rescue Sxn::Error => e
94
+ @ui.error(e.message)
95
+ exit(e.exit_code)
96
+ end
97
+
98
+ desc "create", "Create a new session template"
99
+ option :name, type: :string, aliases: "-n", desc: "Template name"
100
+ option :description, type: :string, aliases: "-d", desc: "Template description"
101
+
102
+ def create
103
+ ensure_initialized!
104
+
105
+ # Get template name
106
+ name = options[:name]
107
+ if name.nil?
108
+ name = @prompt.ask("Template name:")
109
+ if name.nil? || name.strip.empty?
110
+ @ui.error("Template name is required")
111
+ exit(1)
112
+ end
113
+ end
114
+
115
+ # Check if template already exists
116
+ if @template_manager.template_exists?(name)
117
+ @ui.error("Template '#{name}' already exists")
118
+ @ui.recovery_suggestion("Use a different name or remove existing template with 'sxn templates remove #{name}'")
119
+ exit(1)
120
+ end
121
+
122
+ # Get description
123
+ description = options[:description]
124
+ description ||= @prompt.ask("Description (optional):")
125
+
126
+ # Select projects
127
+ projects = @project_manager.list_projects
128
+ if projects.empty?
129
+ @ui.error("No projects configured")
130
+ @ui.recovery_suggestion("Add projects with 'sxn projects add <name> <path>' first")
131
+ exit(1)
132
+ end
133
+
134
+ @ui.newline
135
+ @ui.info("Select projects to include in the template:")
136
+ @ui.info("(Use space to select, enter to confirm)")
137
+ @ui.newline
138
+
139
+ project_choices = projects.map do |p|
140
+ { name: "#{p[:name]} (#{p[:type] || "unknown"})", value: p[:name] }
141
+ end
142
+
143
+ selected_projects = @prompt.multi_select("Projects:", project_choices)
144
+
145
+ if selected_projects.empty?
146
+ @ui.error("At least one project must be selected")
147
+ exit(1)
148
+ end
149
+
150
+ # Create the template
151
+ @ui.progress_start("Creating template '#{name}'")
152
+
153
+ @template_manager.create_template(
154
+ name,
155
+ description: description,
156
+ projects: selected_projects
157
+ )
158
+
159
+ @ui.progress_done
160
+ @ui.success("Template '#{name}' created with #{selected_projects.size} project(s)")
161
+
162
+ @ui.newline
163
+ @ui.subsection("Usage")
164
+ @ui.command_example(
165
+ "sxn add my-session -t #{name} -b my-branch",
166
+ "Create session with this template"
167
+ )
168
+ rescue Sxn::SessionTemplateValidationError => e
169
+ @ui.progress_failed
170
+ @ui.error(e.message)
171
+ exit(1)
172
+ rescue Sxn::Error => e
173
+ @ui.progress_failed
174
+ @ui.error(e.message)
175
+ exit(e.exit_code)
176
+ end
177
+
178
+ desc "remove NAME", "Remove a session template"
179
+ option :force, type: :boolean, aliases: "-f", desc: "Skip confirmation"
180
+
181
+ def remove(name = nil)
182
+ ensure_initialized!
183
+
184
+ # Interactive selection if name not provided
185
+ if name.nil?
186
+ templates = @template_manager.list_templates
187
+ if templates.empty?
188
+ @ui.empty_state("No templates to remove")
189
+ return
190
+ end
191
+
192
+ choices = templates.map { |t| { name: "#{t[:name]} - #{t[:description] || "(no description)"}", value: t[:name] } }
193
+ name = @prompt.select("Select template to remove:", choices)
194
+ end
195
+
196
+ # Verify template exists
197
+ @template_manager.get_template(name)
198
+
199
+ # Confirm deletion unless force flag is used
200
+ return if !options[:force] && !@prompt.confirm_deletion(name, "template")
201
+
202
+ @ui.progress_start("Removing template '#{name}'")
203
+ @template_manager.remove_template(name)
204
+ @ui.progress_done
205
+ @ui.success("Template '#{name}' removed")
206
+ rescue Sxn::SessionTemplateNotFoundError => e
207
+ @ui.error(e.message)
208
+ exit(1)
209
+ rescue Sxn::Error => e
210
+ @ui.progress_failed
211
+ @ui.error(e.message)
212
+ exit(e.exit_code)
213
+ end
214
+
215
+ private
216
+
217
+ def ensure_initialized!
218
+ return if @config_manager.initialized?
219
+
220
+ @ui.error("Project not initialized")
221
+ @ui.recovery_suggestion("Run 'sxn init' to initialize sxn in this project")
222
+ exit(1)
223
+ end
224
+
225
+ def display_templates_table(templates)
226
+ @table.templates(templates)
227
+ end
228
+ end
229
+ end
230
+ end
data/lib/sxn/commands.rb CHANGED
@@ -5,6 +5,7 @@ require_relative "commands/sessions"
5
5
  require_relative "commands/projects"
6
6
  require_relative "commands/worktrees"
7
7
  require_relative "commands/rules"
8
+ require_relative "commands/templates"
8
9
 
9
10
  module Sxn
10
11
  # Commands namespace for all CLI command implementations
@@ -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
data/lib/sxn/ui/table.rb CHANGED
@@ -89,6 +89,21 @@ module Sxn
89
89
  render_table(headers, rows)
90
90
  end
91
91
 
92
+ def templates(templates)
93
+ return empty_table("No templates defined") if templates.empty?
94
+
95
+ headers = %w[Name Description Projects]
96
+ rows = templates.map do |template|
97
+ [
98
+ template[:name],
99
+ template[:description] || "-",
100
+ template[:project_count].to_s
101
+ ]
102
+ end
103
+
104
+ render_table(headers, rows)
105
+ end
106
+
92
107
  # Add a header to the table output
93
108
  def header(title)
94
109
  puts "\n#{@pastel.bold.underline(title)}"
@@ -99,7 +114,9 @@ module Sxn
99
114
 
100
115
  def render_table(headers, rows)
101
116
  table = TTY::Table.new(header: headers, rows: rows)
102
- puts table.render(:unicode, padding: [0, 1])
117
+ # Use basic renderer to avoid terminal width detection issues
118
+ renderer = $stdout.tty? ? :unicode : :basic
119
+ puts table.render(renderer, padding: [0, 1])
103
120
  end
104
121
 
105
122
  def empty_table(message)
data/lib/sxn/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Sxn
4
- VERSION = "0.2.5"
4
+ VERSION = "0.3.0"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: sxn
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.5
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ernest Sim
@@ -509,17 +509,20 @@ files:
509
509
  - lib/sxn/commands/projects.rb
510
510
  - lib/sxn/commands/rules.rb
511
511
  - lib/sxn/commands/sessions.rb
512
+ - lib/sxn/commands/templates.rb
512
513
  - lib/sxn/commands/worktrees.rb
513
514
  - lib/sxn/config.rb
514
515
  - lib/sxn/config/config_cache.rb
515
516
  - lib/sxn/config/config_discovery.rb
516
517
  - lib/sxn/config/config_validator.rb
518
+ - lib/sxn/config/templates_config.rb
517
519
  - lib/sxn/core.rb
518
520
  - lib/sxn/core/config_manager.rb
519
521
  - lib/sxn/core/project_manager.rb
520
522
  - lib/sxn/core/rules_manager.rb
521
523
  - lib/sxn/core/session_config.rb
522
524
  - lib/sxn/core/session_manager.rb
525
+ - lib/sxn/core/template_manager.rb
523
526
  - lib/sxn/core/worktree_manager.rb
524
527
  - lib/sxn/database.rb
525
528
  - lib/sxn/database/errors.rb
@@ -611,7 +614,6 @@ files:
611
614
  - sig/sxn/ui/prompt.rbs
612
615
  - sig/sxn/ui/table.rbs
613
616
  - sig/sxn/version.rbs
614
- - sxn.gemspec
615
617
  homepage: https://github.com/idl3/sxn
616
618
  licenses:
617
619
  - MIT
data/sxn.gemspec DELETED
@@ -1,85 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative "lib/sxn/version"
4
-
5
- Gem::Specification.new do |spec|
6
- spec.name = "sxn"
7
- spec.version = Sxn::VERSION
8
- spec.authors = ["Ernest Sim"]
9
- spec.email = ["ernest.codes@gmail.com"]
10
-
11
- spec.summary = "Session management for multi-repository development"
12
- spec.description = "Sxn simplifies git worktree management with intelligent project rules and secure automation"
13
- spec.homepage = "https://github.com/idl3/sxn"
14
- spec.license = "MIT"
15
- spec.required_ruby_version = ">= 3.2.0"
16
-
17
- spec.metadata["allowed_push_host"] = "https://rubygems.org"
18
- spec.metadata["homepage_uri"] = spec.homepage
19
- spec.metadata["source_code_uri"] = spec.homepage
20
- spec.metadata["changelog_uri"] = "#{spec.homepage}/blob/main/CHANGELOG.md"
21
- spec.metadata["rubygems_mfa_required"] = "true"
22
-
23
- # Specify which files should be added to the gem when it is released.
24
- # The `git ls-files -z` loads the files in the RubyGem that have been added into git.
25
- spec.files = Dir.chdir(File.expand_path(__dir__)) do
26
- `git ls-files -z`.split("\x0").reject do |f|
27
- (f == __FILE__) ||
28
- f.match(%r{\A(?:(?:test|spec|features)/|\.(?:git|travis|circleci)|appveyor)}) ||
29
- f.match(/\.db-(?:shm|wal)\z/) || # Exclude SQLite temp files
30
- f.match(/\.gem\z/) # Exclude gem files
31
- end
32
- end
33
-
34
- spec.bindir = "bin"
35
- spec.executables = spec.files.grep(%r{\Abin/}) { |f| File.basename(f) }
36
- spec.require_paths = ["lib"]
37
-
38
- # Core CLI dependencies
39
- spec.add_dependency "pastel", "~> 0.8" # Terminal colors
40
- spec.add_dependency "thor", "~> 1.3" # CLI framework
41
- spec.add_dependency "tty-progressbar", "~> 0.18" # Progress bars
42
- spec.add_dependency "tty-prompt", "~> 0.23" # Interactive prompts
43
- spec.add_dependency "tty-table", "~> 0.12" # Table formatting
44
-
45
- # Configuration and data management
46
- spec.add_dependency "dry-configurable", "~> 1.0" # Configuration management
47
- spec.add_dependency "sqlite3", "~> 1.6" # Session database
48
- spec.add_dependency "zeitwerk", "~> 2.6" # Code loading
49
-
50
- # Template engine (secure, sandboxed)
51
- spec.add_dependency "liquid", "~> 5.4" # Safe template processing
52
-
53
- # MCP server dependencies
54
- spec.add_dependency "async", "~> 2.0" # Async operations
55
- spec.add_dependency "json-schema", "~> 4.0" # Schema validation
56
-
57
- # Security and encryption
58
- spec.add_dependency "bcrypt", "~> 3.1" # Password hashing
59
- spec.add_dependency "openssl", ">= 3.0" # Encryption support
60
- spec.add_dependency "ostruct" # OpenStruct for Ruby 3.5+ compatibility
61
-
62
- # File system operations
63
- spec.add_dependency "listen", "~> 3.8" # File watching for config cache
64
- spec.add_dependency "parallel", "~> 1.23" # Parallel execution
65
-
66
- # Development dependencies
67
- spec.add_development_dependency "aruba", "~> 2.1" # CLI testing
68
- spec.add_development_dependency "benchmark" # Benchmark for Ruby 3.5+ compatibility
69
- spec.add_development_dependency "benchmark-ips", "~> 2.12" # Performance benchmarking
70
- spec.add_development_dependency "bundler", "~> 2.4"
71
- spec.add_development_dependency "climate_control", "~> 1.2" # Environment variable testing
72
- spec.add_development_dependency "faker", "~> 3.2" # Test data generation
73
- spec.add_development_dependency "memory_profiler", "~> 1.0" # Memory profiling
74
- spec.add_development_dependency "rake", "~> 13.0"
75
- spec.add_development_dependency "rspec", "~> 3.12"
76
- spec.add_development_dependency "rubocop", "~> 1.50" # Code linting
77
- spec.add_development_dependency "rubocop-performance", "~> 1.16"
78
- spec.add_development_dependency "rubocop-rspec", "~> 2.19"
79
- spec.add_development_dependency "simplecov", "~> 0.22" # Code coverage
80
- spec.add_development_dependency "vcr", "~> 6.2" # HTTP interaction recording
81
- spec.add_development_dependency "webmock", "~> 3.19" # HTTP mocking for MCP tests
82
-
83
- # For more information and examples about making a new gem, check out our
84
- # guide at: https://bundler.io/guides/creating_gem.html
85
- end