sxn 0.3.0 → 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
@@ -329,10 +329,7 @@ module Sxn
329
329
 
330
330
  @ui.progress_done
331
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)
332
+ # Apply project-specific rule overrides if defined
336
333
  apply_template_rules(session_name, project_name, worktree[:path], project_config["rules"]) if project_config["rules"]
337
334
  end
338
335
 
@@ -62,11 +62,7 @@ module Sxn
62
62
  else
63
63
  projects.each do |project_config|
64
64
  project_name = project_config["name"]
65
- project = begin
66
- @project_manager.get_project(project_name)
67
- rescue Sxn::ProjectNotFoundError
68
- nil
69
- end
65
+ project = @project_manager.get_project(project_name)
70
66
  status = project ? "✓" : "✗ (not found)"
71
67
 
72
68
  details = []
data/lib/sxn/commands.rb CHANGED
@@ -6,6 +6,7 @@ require_relative "commands/projects"
6
6
  require_relative "commands/worktrees"
7
7
  require_relative "commands/rules"
8
8
  require_relative "commands/templates"
9
+ require_relative "commands/mcp"
9
10
 
10
11
  module Sxn
11
12
  # Commands namespace for all CLI command implementations
@@ -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
@@ -0,0 +1,145 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sxn
4
+ module MCP
5
+ module Resources
6
+ # Factory methods for creating MCP Resource objects
7
+ module SessionResources
8
+ class << self
9
+ # Build all resources for the server
10
+ def build_all(server_context)
11
+ [
12
+ build_current_session(server_context),
13
+ build_all_sessions(server_context),
14
+ build_all_projects(server_context)
15
+ ].compact
16
+ end
17
+
18
+ private
19
+
20
+ def build_current_session(server_context)
21
+ return nil unless server_context[:config_manager]
22
+
23
+ ::MCP::Resource.new(
24
+ uri: "sxn://session/current",
25
+ name: "Current Session",
26
+ description: "Information about the currently active sxn session",
27
+ mime_type: "application/json"
28
+ )
29
+ end
30
+
31
+ def build_all_sessions(server_context)
32
+ return nil unless server_context[:config_manager]
33
+
34
+ ::MCP::Resource.new(
35
+ uri: "sxn://sessions",
36
+ name: "All Sessions",
37
+ description: "Summary of all sxn sessions",
38
+ mime_type: "application/json"
39
+ )
40
+ end
41
+
42
+ def build_all_projects(server_context)
43
+ return nil unless server_context[:config_manager]
44
+
45
+ ::MCP::Resource.new(
46
+ uri: "sxn://projects",
47
+ name: "Registered Projects",
48
+ description: "All registered projects in the sxn workspace",
49
+ mime_type: "application/json"
50
+ )
51
+ end
52
+ end
53
+ end
54
+
55
+ # Resource content readers
56
+ module ResourceContentReader
57
+ class << self
58
+ def read_content(uri, server_context)
59
+ case uri
60
+ when "sxn://session/current"
61
+ read_current_session(server_context)
62
+ when "sxn://sessions"
63
+ read_all_sessions(server_context)
64
+ when "sxn://projects"
65
+ read_all_projects(server_context)
66
+ else
67
+ JSON.generate({ error: "Unknown resource: #{uri}" })
68
+ end
69
+ end
70
+
71
+ private
72
+
73
+ def read_current_session(server_context)
74
+ config_manager = server_context[:config_manager]
75
+ return JSON.generate({ error: "sxn not initialized" }) unless config_manager
76
+
77
+ session_manager = server_context[:session_manager]
78
+ session = session_manager.current_session
79
+
80
+ unless session
81
+ return JSON.generate({
82
+ current_session: nil,
83
+ message: "No active session"
84
+ })
85
+ end
86
+
87
+ JSON.generate({
88
+ name: session[:name],
89
+ status: session[:status],
90
+ path: session[:path],
91
+ created_at: session[:created_at],
92
+ default_branch: session[:default_branch],
93
+ worktrees: session[:worktrees],
94
+ projects: session[:projects]
95
+ })
96
+ rescue StandardError => e
97
+ JSON.generate({ error: e.message })
98
+ end
99
+
100
+ def read_all_sessions(server_context)
101
+ config_manager = server_context[:config_manager]
102
+ return JSON.generate({ error: "sxn not initialized" }) unless config_manager
103
+
104
+ session_manager = server_context[:session_manager]
105
+ sessions = session_manager.list_sessions
106
+
107
+ JSON.generate({
108
+ total: sessions.length,
109
+ sessions: sessions.map do |s|
110
+ {
111
+ name: s[:name],
112
+ status: s[:status],
113
+ worktree_count: s[:worktrees].keys.length
114
+ }
115
+ end
116
+ })
117
+ rescue StandardError => e
118
+ JSON.generate({ error: e.message })
119
+ end
120
+
121
+ def read_all_projects(server_context)
122
+ config_manager = server_context[:config_manager]
123
+ return JSON.generate({ error: "sxn not initialized" }) unless config_manager
124
+
125
+ project_manager = server_context[:project_manager]
126
+ projects = project_manager.list_projects
127
+
128
+ JSON.generate({
129
+ total: projects.length,
130
+ projects: projects.map do |p|
131
+ {
132
+ name: p[:name],
133
+ type: p[:type],
134
+ path: p[:path]
135
+ }
136
+ end
137
+ })
138
+ rescue StandardError => e
139
+ JSON.generate({ error: e.message })
140
+ end
141
+ end
142
+ end
143
+ end
144
+ end
145
+ end
@@ -0,0 +1,127 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sxn
4
+ module MCP
5
+ # Main MCP server class that orchestrates tools, resources, and prompts
6
+ class Server
7
+ attr_reader :workspace_path, :config_manager
8
+
9
+ def initialize(workspace_path: nil)
10
+ @workspace_path = workspace_path || ENV["SXN_WORKSPACE"] || discover_workspace
11
+ @config_manager = initialize_config_manager
12
+ @server = build_mcp_server
13
+ end
14
+
15
+ # Run the server with STDIO transport (for Claude Code integration)
16
+ def run_stdio
17
+ transport = ::MCP::Server::Transports::StdioTransport.new(@server)
18
+ transport.open
19
+ end
20
+
21
+ # Run the server with HTTP transport (for web-based integrations)
22
+ def run_http(port: 3000)
23
+ transport = ::MCP::Server::Transports::StreamableHTTPTransport.new(@server)
24
+ transport.run(port: port)
25
+ end
26
+
27
+ private
28
+
29
+ def discover_workspace
30
+ # Try to find .sxn directory in current or parent directories
31
+ current = Dir.pwd
32
+ loop do
33
+ sxn_path = File.join(current, ".sxn")
34
+ return current if File.directory?(sxn_path)
35
+
36
+ parent = File.dirname(current)
37
+ break if parent == current
38
+
39
+ current = parent
40
+ end
41
+
42
+ # Fall back to current directory
43
+ Dir.pwd
44
+ end
45
+
46
+ def initialize_config_manager
47
+ Sxn::Core::ConfigManager.new(@workspace_path)
48
+ rescue Sxn::ConfigurationError
49
+ # Allow server to start even if sxn isn't initialized
50
+ # Tools will return appropriate errors
51
+ nil
52
+ end
53
+
54
+ def build_mcp_server
55
+ context = build_context
56
+
57
+ server = ::MCP::Server.new(
58
+ name: "sxn",
59
+ version: Sxn::VERSION,
60
+ tools: registered_tools,
61
+ resources: registered_resources(context),
62
+ prompts: registered_prompts,
63
+ server_context: context
64
+ )
65
+
66
+ # Set up resource read handler
67
+ server.resources_read_handler do |params|
68
+ uri = params[:uri]
69
+ content = Resources::ResourceContentReader.read_content(uri, context)
70
+ [{ uri: uri, text: content, mimeType: "application/json" }]
71
+ end
72
+
73
+ server
74
+ end
75
+
76
+ def build_context
77
+ {
78
+ config_manager: @config_manager,
79
+ session_manager: @config_manager && Sxn::Core::SessionManager.new(@config_manager),
80
+ project_manager: @config_manager && Sxn::Core::ProjectManager.new(@config_manager),
81
+ worktree_manager: @config_manager && Sxn::Core::WorktreeManager.new(@config_manager),
82
+ template_manager: @config_manager && Sxn::Core::TemplateManager.new(@config_manager),
83
+ rules_manager: @config_manager && Sxn::Core::RulesManager.new(@config_manager),
84
+ workspace_path: @workspace_path
85
+ }
86
+ end
87
+
88
+ def registered_tools
89
+ [
90
+ # Session tools
91
+ Tools::Sessions::ListSessions,
92
+ Tools::Sessions::CreateSession,
93
+ Tools::Sessions::GetSession,
94
+ Tools::Sessions::DeleteSession,
95
+ Tools::Sessions::ArchiveSession,
96
+ Tools::Sessions::ActivateSession,
97
+ Tools::Sessions::SwapSession,
98
+ # Worktree tools
99
+ Tools::Worktrees::ListWorktrees,
100
+ Tools::Worktrees::AddWorktree,
101
+ Tools::Worktrees::RemoveWorktree,
102
+ # Project tools
103
+ Tools::Projects::ListProjects,
104
+ Tools::Projects::AddProject,
105
+ Tools::Projects::GetProject,
106
+ # Template tools
107
+ Tools::Templates::ListTemplates,
108
+ Tools::Templates::ApplyTemplate,
109
+ # Rules tools
110
+ Tools::Rules::ListRules,
111
+ Tools::Rules::ApplyRules
112
+ ]
113
+ end
114
+
115
+ def registered_resources(context)
116
+ Resources::SessionResources.build_all(context)
117
+ end
118
+
119
+ def registered_prompts
120
+ [
121
+ Prompts::NewSession,
122
+ Prompts::MultiRepoSetup
123
+ ]
124
+ end
125
+ end
126
+ end
127
+ end