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,219 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "thor"
4
+ require "json"
5
+ require "English"
6
+
7
+ module Sxn
8
+ module Commands
9
+ # Manage MCP server for Claude Code integration
10
+ class MCP < Thor
11
+ include Thor::Actions
12
+
13
+ def initialize(args = ARGV, local_options = {}, config = {})
14
+ super
15
+ @ui = Sxn::UI::Output.new
16
+ end
17
+
18
+ desc "install", "Install sxn MCP server to Claude Code"
19
+ option :project, type: :boolean, aliases: "-p", desc: "Install to project .mcp.json instead of user config"
20
+ def install
21
+ sxn_mcp_path = find_sxn_mcp_executable
22
+
23
+ if sxn_mcp_path.nil?
24
+ @ui.error("Could not find sxn-mcp executable")
25
+ @ui.recovery_suggestion("Ensure the sxn gem is properly installed")
26
+ exit(1)
27
+ end
28
+
29
+ if options[:project]
30
+ install_to_project(sxn_mcp_path)
31
+ else
32
+ install_to_claude_code(sxn_mcp_path)
33
+ end
34
+ end
35
+
36
+ desc "uninstall", "Remove sxn MCP server from Claude Code"
37
+ option :project, type: :boolean, aliases: "-p", desc: "Remove from project .mcp.json"
38
+ def uninstall
39
+ if options[:project]
40
+ uninstall_from_project
41
+ else
42
+ uninstall_from_claude_code
43
+ end
44
+ end
45
+
46
+ desc "status", "Check if sxn MCP server is installed"
47
+ def status
48
+ @ui.section("MCP Server Status")
49
+
50
+ claude_installed = check_claude_installation
51
+ project_installed = check_project_installation
52
+
53
+ @ui.newline
54
+ @ui.key_value("Claude Code (user)", claude_installed ? "Installed" : "Not installed")
55
+ @ui.key_value("Project (.mcp.json)", project_installed ? "Installed" : "Not installed")
56
+
57
+ sxn_mcp_path = find_sxn_mcp_executable
58
+ @ui.newline
59
+ if sxn_mcp_path
60
+ @ui.key_value("Executable", sxn_mcp_path)
61
+ else
62
+ @ui.warning("sxn-mcp executable not found in PATH")
63
+ end
64
+
65
+ @ui.newline
66
+ display_install_commands unless claude_installed || project_installed
67
+ end
68
+
69
+ desc "server", "Run the MCP server directly (for testing)"
70
+ option :transport, type: :string, default: "stdio", enum: %w[stdio http], desc: "Transport type"
71
+ option :port, type: :numeric, default: 3000, desc: "Port for HTTP transport"
72
+ def server
73
+ require "sxn/mcp"
74
+
75
+ mcp_server = Sxn::MCP::Server.new
76
+
77
+ case options[:transport]
78
+ when "stdio"
79
+ @ui.info("Starting MCP server with STDIO transport...")
80
+ mcp_server.run_stdio
81
+ when "http"
82
+ @ui.info("Starting MCP server on port #{options[:port]}...")
83
+ mcp_server.run_http(port: options[:port])
84
+ end
85
+ end
86
+
87
+ private
88
+
89
+ def find_sxn_mcp_executable
90
+ # Check if sxn-mcp is in PATH
91
+ path = `which sxn-mcp 2>/dev/null`.strip
92
+ return path unless path.empty?
93
+
94
+ # Check relative to this gem's installation
95
+ gem_bin_path = File.expand_path("../../../bin/sxn-mcp", __dir__)
96
+ return gem_bin_path if File.exist?(gem_bin_path)
97
+
98
+ nil
99
+ end
100
+
101
+ def install_to_claude_code(sxn_mcp_path)
102
+ unless claude_cli_available?
103
+ @ui.error("Claude CLI not found")
104
+ @ui.recovery_suggestion("Install Claude Code CLI first: https://claude.ai/code")
105
+ exit(1)
106
+ end
107
+
108
+ @ui.progress_start("Installing sxn MCP server to Claude Code")
109
+
110
+ # claude mcp add --transport stdio sxn -- /path/to/sxn-mcp
111
+ success = system("claude", "mcp", "add", "--transport", "stdio", "sxn", "--", sxn_mcp_path)
112
+
113
+ if success
114
+ @ui.progress_done
115
+ @ui.success("sxn MCP server installed to Claude Code")
116
+ @ui.info("Restart Claude Code to use the new server")
117
+ else
118
+ @ui.progress_failed
119
+ @ui.error("Failed to install MCP server")
120
+ @ui.recovery_suggestion("Try running manually: claude mcp add --transport stdio sxn -- #{sxn_mcp_path}")
121
+ exit(1)
122
+ end
123
+ end
124
+
125
+ def install_to_project(sxn_mcp_path)
126
+ mcp_json_path = File.join(Dir.pwd, ".mcp.json")
127
+
128
+ mcp_config = if File.exist?(mcp_json_path)
129
+ JSON.parse(File.read(mcp_json_path))
130
+ else
131
+ { "mcpServers" => {} }
132
+ end
133
+
134
+ mcp_config["mcpServers"] ||= {}
135
+ mcp_config["mcpServers"]["sxn"] = {
136
+ "command" => sxn_mcp_path,
137
+ "args" => [],
138
+ "env" => { "SXN_WORKSPACE" => "${PWD}" }
139
+ }
140
+
141
+ File.write(mcp_json_path, JSON.pretty_generate(mcp_config))
142
+
143
+ @ui.success("Created/updated .mcp.json with sxn MCP server")
144
+ @ui.info("This file can be version controlled to share with your team")
145
+ end
146
+
147
+ def uninstall_from_claude_code
148
+ unless claude_cli_available?
149
+ @ui.error("Claude CLI not found")
150
+ exit(1)
151
+ end
152
+
153
+ @ui.progress_start("Removing sxn MCP server from Claude Code")
154
+
155
+ success = system("claude", "mcp", "remove", "sxn")
156
+
157
+ if success
158
+ @ui.progress_done
159
+ @ui.success("sxn MCP server removed from Claude Code")
160
+ else
161
+ @ui.progress_failed
162
+ @ui.error("Failed to remove MCP server (it may not be installed)")
163
+ end
164
+ end
165
+
166
+ def uninstall_from_project
167
+ mcp_json_path = File.join(Dir.pwd, ".mcp.json")
168
+
169
+ unless File.exist?(mcp_json_path)
170
+ @ui.info("No .mcp.json file found in current directory")
171
+ return
172
+ end
173
+
174
+ mcp_config = JSON.parse(File.read(mcp_json_path))
175
+
176
+ if mcp_config.dig("mcpServers", "sxn")
177
+ mcp_config["mcpServers"].delete("sxn")
178
+
179
+ if mcp_config["mcpServers"].empty?
180
+ File.delete(mcp_json_path)
181
+ @ui.success("Removed .mcp.json file (no servers remaining)")
182
+ else
183
+ File.write(mcp_json_path, JSON.pretty_generate(mcp_config))
184
+ @ui.success("Removed sxn from .mcp.json")
185
+ end
186
+ else
187
+ @ui.info("sxn not found in .mcp.json")
188
+ end
189
+ end
190
+
191
+ def check_claude_installation
192
+ return false unless claude_cli_available?
193
+
194
+ output = `claude mcp get sxn 2>/dev/null`
195
+ $CHILD_STATUS.success? && !output.strip.empty?
196
+ end
197
+
198
+ def check_project_installation
199
+ mcp_json_path = File.join(Dir.pwd, ".mcp.json")
200
+ return false unless File.exist?(mcp_json_path)
201
+
202
+ mcp_config = JSON.parse(File.read(mcp_json_path))
203
+ !mcp_config.dig("mcpServers", "sxn").nil?
204
+ rescue JSON::ParserError
205
+ false
206
+ end
207
+
208
+ def claude_cli_available?
209
+ system("which claude > /dev/null 2>&1")
210
+ end
211
+
212
+ def display_install_commands
213
+ @ui.subsection("Install Commands")
214
+ @ui.command_example("sxn mcp install", "Install to Claude Code (user scope)")
215
+ @ui.command_example("sxn mcp install --project", "Install to project .mcp.json")
216
+ end
217
+ end
218
+ end
219
+ end
@@ -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,85 @@ 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-specific rule overrides if defined
333
+ apply_template_rules(session_name, project_name, worktree[:path], project_config["rules"]) if project_config["rules"]
334
+ end
335
+
336
+ @ui.newline
337
+ @ui.success("Template applied: #{created_worktrees.size} worktree(s) created")
338
+
339
+ # Display created worktrees
340
+ @ui.newline
341
+ @ui.subsection("Created Worktrees")
342
+ created_worktrees.each do |wt|
343
+ @ui.list_item("#{wt[:project]} (#{wt[:branch]}) → #{wt[:path]}")
344
+ end
345
+ rescue StandardError => e
346
+ # ATOMIC ROLLBACK: Remove all created worktrees and the session
347
+ @ui.progress_failed
348
+ @ui.newline
349
+ @ui.warning("Template application failed, rolling back...")
350
+
351
+ rollback_template_application(session_name, created_worktrees)
352
+
353
+ raise Sxn::SessionTemplateApplicationError.new(template_id, e.message)
354
+ end
355
+ end
356
+
357
+ def rollback_template_application(session_name, created_worktrees)
358
+ # Remove created worktrees
359
+ created_worktrees.each do |wt|
360
+ @worktree_manager.remove_worktree(wt[:project], session_name: session_name)
361
+ rescue StandardError
362
+ # Best effort cleanup, continue with rollback
363
+ nil
364
+ end
365
+
366
+ # Remove the session
367
+ @session_manager.remove_session(session_name, force: true)
368
+ @ui.info("Session '#{session_name}' has been rolled back")
369
+ rescue StandardError => e
370
+ @ui.warning("Rollback encountered errors: #{e.message}")
371
+ end
372
+
373
+ def apply_template_rules(_session_name, project_name, worktree_path, rules)
374
+ return unless rules.is_a?(Hash)
375
+
376
+ rules_manager = Sxn::Core::RulesManager.new(@config_manager, worktree_path)
377
+
378
+ rules_manager.apply_copy_files_rules(rules["copy_files"]) if rules["copy_files"]
379
+
380
+ rules_manager.apply_setup_commands(rules["setup_commands"]) if rules["setup_commands"]
381
+ rescue StandardError => e
382
+ @ui.warning("Failed to apply rules for #{project_name}: #{e.message}")
383
+ end
384
+
278
385
  def ensure_initialized!
279
386
  return if @config_manager.initialized?
280
387
 
@@ -0,0 +1,226 @@
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 = @project_manager.get_project(project_name)
66
+ status = project ? "✓" : "✗ (not found)"
67
+
68
+ details = []
69
+ details << "branch: #{project_config["branch"]}" if project_config["branch"]
70
+ details << "has custom rules" if project_config["rules"]
71
+
72
+ if details.any?
73
+ @ui.list_item("#{project_name} #{status} (#{details.join(", ")})")
74
+ else
75
+ @ui.list_item("#{project_name} #{status}")
76
+ end
77
+ end
78
+ end
79
+
80
+ @ui.newline
81
+ @ui.subsection("Usage")
82
+ @ui.command_example(
83
+ "sxn add my-session -t #{name} -b my-branch",
84
+ "Create session with this template"
85
+ )
86
+ rescue Sxn::SessionTemplateNotFoundError => e
87
+ @ui.error(e.message)
88
+ exit(1)
89
+ rescue Sxn::Error => e
90
+ @ui.error(e.message)
91
+ exit(e.exit_code)
92
+ end
93
+
94
+ desc "create", "Create a new session template"
95
+ option :name, type: :string, aliases: "-n", desc: "Template name"
96
+ option :description, type: :string, aliases: "-d", desc: "Template description"
97
+
98
+ def create
99
+ ensure_initialized!
100
+
101
+ # Get template name
102
+ name = options[:name]
103
+ if name.nil?
104
+ name = @prompt.ask("Template name:")
105
+ if name.nil? || name.strip.empty?
106
+ @ui.error("Template name is required")
107
+ exit(1)
108
+ end
109
+ end
110
+
111
+ # Check if template already exists
112
+ if @template_manager.template_exists?(name)
113
+ @ui.error("Template '#{name}' already exists")
114
+ @ui.recovery_suggestion("Use a different name or remove existing template with 'sxn templates remove #{name}'")
115
+ exit(1)
116
+ end
117
+
118
+ # Get description
119
+ description = options[:description]
120
+ description ||= @prompt.ask("Description (optional):")
121
+
122
+ # Select projects
123
+ projects = @project_manager.list_projects
124
+ if projects.empty?
125
+ @ui.error("No projects configured")
126
+ @ui.recovery_suggestion("Add projects with 'sxn projects add <name> <path>' first")
127
+ exit(1)
128
+ end
129
+
130
+ @ui.newline
131
+ @ui.info("Select projects to include in the template:")
132
+ @ui.info("(Use space to select, enter to confirm)")
133
+ @ui.newline
134
+
135
+ project_choices = projects.map do |p|
136
+ { name: "#{p[:name]} (#{p[:type] || "unknown"})", value: p[:name] }
137
+ end
138
+
139
+ selected_projects = @prompt.multi_select("Projects:", project_choices)
140
+
141
+ if selected_projects.empty?
142
+ @ui.error("At least one project must be selected")
143
+ exit(1)
144
+ end
145
+
146
+ # Create the template
147
+ @ui.progress_start("Creating template '#{name}'")
148
+
149
+ @template_manager.create_template(
150
+ name,
151
+ description: description,
152
+ projects: selected_projects
153
+ )
154
+
155
+ @ui.progress_done
156
+ @ui.success("Template '#{name}' created with #{selected_projects.size} project(s)")
157
+
158
+ @ui.newline
159
+ @ui.subsection("Usage")
160
+ @ui.command_example(
161
+ "sxn add my-session -t #{name} -b my-branch",
162
+ "Create session with this template"
163
+ )
164
+ rescue Sxn::SessionTemplateValidationError => e
165
+ @ui.progress_failed
166
+ @ui.error(e.message)
167
+ exit(1)
168
+ rescue Sxn::Error => e
169
+ @ui.progress_failed
170
+ @ui.error(e.message)
171
+ exit(e.exit_code)
172
+ end
173
+
174
+ desc "remove NAME", "Remove a session template"
175
+ option :force, type: :boolean, aliases: "-f", desc: "Skip confirmation"
176
+
177
+ def remove(name = nil)
178
+ ensure_initialized!
179
+
180
+ # Interactive selection if name not provided
181
+ if name.nil?
182
+ templates = @template_manager.list_templates
183
+ if templates.empty?
184
+ @ui.empty_state("No templates to remove")
185
+ return
186
+ end
187
+
188
+ choices = templates.map { |t| { name: "#{t[:name]} - #{t[:description] || "(no description)"}", value: t[:name] } }
189
+ name = @prompt.select("Select template to remove:", choices)
190
+ end
191
+
192
+ # Verify template exists
193
+ @template_manager.get_template(name)
194
+
195
+ # Confirm deletion unless force flag is used
196
+ return if !options[:force] && !@prompt.confirm_deletion(name, "template")
197
+
198
+ @ui.progress_start("Removing template '#{name}'")
199
+ @template_manager.remove_template(name)
200
+ @ui.progress_done
201
+ @ui.success("Template '#{name}' removed")
202
+ rescue Sxn::SessionTemplateNotFoundError => e
203
+ @ui.error(e.message)
204
+ exit(1)
205
+ rescue Sxn::Error => e
206
+ @ui.progress_failed
207
+ @ui.error(e.message)
208
+ exit(e.exit_code)
209
+ end
210
+
211
+ private
212
+
213
+ def ensure_initialized!
214
+ return if @config_manager.initialized?
215
+
216
+ @ui.error("Project not initialized")
217
+ @ui.recovery_suggestion("Run 'sxn init' to initialize sxn in this project")
218
+ exit(1)
219
+ end
220
+
221
+ def display_templates_table(templates)
222
+ @table.templates(templates)
223
+ end
224
+ end
225
+ end
226
+ end
data/lib/sxn/commands.rb CHANGED
@@ -5,6 +5,8 @@ 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"
9
+ require_relative "commands/mcp"
8
10
 
9
11
  module Sxn
10
12
  # Commands namespace for all CLI command implementations