diff --git a/src/SeqCli/Cli/Commands/Skills/InstallCommand.cs b/src/SeqCli/Cli/Commands/Skills/InstallCommand.cs index 8b6c634e..a0278f55 100644 --- a/src/SeqCli/Cli/Commands/Skills/InstallCommand.cs +++ b/src/SeqCli/Cli/Commands/Skills/InstallCommand.cs @@ -29,9 +29,9 @@ public InstallCommand() { Options.Add( "g|global", - "Install skills globally, to `~/.{agent}/skills`; the default is to install locally, in `./{agent}/skills`", + "Install skills to the agent's user-level directory (e.g. `~/.{agent}/skills`); the default is to install locally, in `./.{agent}/skills`", _ => _global = true); - + Options.Add( "a=|agent=", "The agent name to install skills for; the default is the generic name `agents`", diff --git a/src/SeqCli/Mcp/McpServerInstaller.cs b/src/SeqCli/Mcp/McpServerInstaller.cs index d9612102..52bc4596 100644 --- a/src/SeqCli/Mcp/McpServerInstaller.cs +++ b/src/SeqCli/Mcp/McpServerInstaller.cs @@ -24,53 +24,91 @@ static class McpServerInstaller { const string ServerName = "seq"; - // Agents whose MCP config location or shape diverges from the common - // `.{agent}/mcp.json` + `mcpServers` convention. Anything not listed here - - // including the default `agents` name and any unknown agent - uses the - // convention (see `Convention`), so adding support for a conformant agent - // requires no change at all, and a divergent one is a single entry here. static readonly IReadOnlyDictionary KnownAgents = new Dictionary { - // Claude Code reads project servers from a root `.mcp.json`, and - // user-global servers from `~/.claude.json`. ["claude"] = new( global => global ? Path.Combine(UserProfile, ".claude.json") : Path.Combine(Environment.CurrentDirectory, ".mcp.json"), "mcpServers"), - // Windsurf keeps a single user-global config under `~/.codeium`. ["windsurf"] = new( global => global ? Path.Combine(UserProfile, ".codeium", "windsurf", "mcp_config.json") - : Path.Combine(Environment.CurrentDirectory, ".windsurf", "mcp.json"), + : throw new NotSupportedException( + "Windsurf only supports a user-global MCP config; re-run with `--global`."), "mcpServers"), - // VS Code nests servers under a `servers` key. Project config lives in - // `.vscode/mcp.json`; the user-global equivalent lives inside `settings.json`, - // which is a different merge target and isn't supported here yet. ["vscode"] = new( global => global - ? throw new NotSupportedException( - "VS Code stores user-level MCP servers in settings.json; install into a project with `seqcli mcp install --agent vscode` instead.") + ? Path.Combine(VsCodeUserDir, "mcp.json") : Path.Combine(Environment.CurrentDirectory, ".vscode", "mcp.json"), "servers"), + + ["copilot"] = new( + global => global + ? Path.Combine(UserProfile, ".copilot", "mcp-config.json") + : throw new NotSupportedException( + "GitHub Copilot only supports a user-global MCP config; re-run with `--global`."), + "mcpServers"), - // Qwen Code reads MCP servers from the `mcpServers` key of its `settings.json`, - // both user-global (`~/.qwen`) and per-project (`.qwen`) - not a standalone `mcp.json`. ["qwen"] = new( global => Path.Combine( global ? UserProfile : Environment.CurrentDirectory, ".qwen", "settings.json"), "mcpServers"), + + ["gemini"] = new( + global => Path.Combine( + global ? UserProfile : Environment.CurrentDirectory, + ".gemini", + "settings.json"), + "mcpServers"), + + ["zed"] = new( + global => global + ? Path.Combine(XdgConfigHome, "zed", "settings.json") + : Path.Combine(Environment.CurrentDirectory, ".zed", "settings.json"), + "context_servers"), + + ["amazonq"] = new( + global => global + ? Path.Combine(UserProfile, ".aws", "amazonq", "mcp.json") + : Path.Combine(Environment.CurrentDirectory, ".amazonq", "mcp.json"), + "mcpServers"), + + ["roo"] = new( + global => global + ? throw new NotSupportedException( + "Roo Code stores user-global MCP servers in VS Code extension storage; install into a project instead.") + : Path.Combine(Environment.CurrentDirectory, ".roo", "mcp.json"), + "mcpServers"), + + ["codex"] = Unsupported( + "Codex reads MCP servers from ~/.codex/config.toml (TOML), which seqcli can't edit automatically. Add this block:\n\n[mcp_servers.seq]\ncommand = \"seqcli\"\nargs = [\"mcp\", \"run\"]"), + + ["goose"] = Unsupported( + "Goose reads MCP servers from ~/.config/goose/config.yaml (YAML) under `extensions`, which seqcli can't edit automatically. Add:\n\nextensions:\n seq:\n type: stdio\n cmd: seqcli\n args: [mcp, run]\n enabled: true"), + + ["continue"] = Unsupported( + "Continue reads MCP servers from YAML, which seqcli can't edit automatically. Create .continue/mcpServers/seq.yaml with:\n\nname: Seq\nversion: 0.0.1\nschema: v1\nmcpServers:\n - name: seq\n command: seqcli\n args:\n - mcp\n - run"), + }; + + static readonly IReadOnlyDictionary AgentAliases = + new Dictionary + { + ["github"] = "copilot" }; public static void Install(string? agent, bool global, string? profileName = null) { agent ??= "agents"; + if (AgentAliases.TryGetValue(agent, out var alias)) + agent = alias; + var target = KnownAgents.TryGetValue(agent, out var known) ? known : Convention(agent); var path = target.ResolvePath(global); @@ -98,12 +136,19 @@ public static void Install(string? agent, bool global, string? profileName = nul ["args"] = args, }; + Console.Write("Installing MCP server to `{0}`...", path); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); File.WriteAllText(path, root.ToString(Newtonsoft.Json.Formatting.Indented)); + Console.WriteLine(" Done."); + Log.Information("Installed Seq MCP server for {Agent} to {Path}", agent, path); } + static AgentTarget Unsupported(string message) => + new(_ => throw new NotSupportedException(message), "mcpServers"); + static AgentTarget Convention(string agent) => new( global => Path.Combine( @@ -114,5 +159,18 @@ static AgentTarget Convention(string agent) => static string UserProfile => Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + static string XdgConfigHome => + Environment.GetEnvironmentVariable("XDG_CONFIG_HOME") is { Length: > 0 } configHome + ? configHome + : Path.Combine(UserProfile, ".config"); + + // VS Code keeps per-user data in an OS-specific directory. + static string VsCodeUserDir => + OperatingSystem.IsWindows() + ? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "Code", "User") + : OperatingSystem.IsMacOS() + ? Path.Combine(UserProfile, "Library", "Application Support", "Code", "User") + : Path.Combine(XdgConfigHome, "Code", "User"); + sealed record AgentTarget(Func ResolvePath, string ServerMapKey); } diff --git a/src/SeqCli/Skills/SkillInstaller.cs b/src/SeqCli/Skills/SkillInstaller.cs index 57b96f9c..eec37480 100644 --- a/src/SeqCli/Skills/SkillInstaller.cs +++ b/src/SeqCli/Skills/SkillInstaller.cs @@ -13,6 +13,7 @@ // limitations under the License. using System; +using System.Collections.Generic; using System.IO; using Serilog; @@ -20,14 +21,31 @@ namespace SeqCli.Skills; static class SkillInstaller { + static readonly IReadOnlyDictionary KnownAgents = + new Dictionary + { + ["copilot"] = new(global => global + ? Path.Combine(UserProfile, ".copilot", "skills") + : Path.Combine(Environment.CurrentDirectory, ".github", "skills")), + }; + + static readonly IReadOnlyDictionary AgentAliases = + new Dictionary + { + ["goose"] = "agents", + ["github"] = "copilot", + ["codex"] = "agents" + }; + public static void Install(string? agent, bool global) { agent ??= "agents"; - var destinationPath = Path.Combine( - global ? UserProfile : Environment.CurrentDirectory, - $".{agent}", - "skills"); + if (AgentAliases.TryGetValue(agent, out var alias)) + agent = alias; + + var target = KnownAgents.TryGetValue(agent, out var known) ? known : Convention(agent); + var destinationPath = target.ResolveSkillsDirectory(global); Log.Information("Installing skills to {SkillsPath}", destinationPath); @@ -38,12 +56,20 @@ public static void Install(string? agent, bool global) var skillName = Path.GetFileName(skillSourceDirectory); var destination = Path.Combine(destinationPath, skillName); - Log.Information("Installing skill {SkillName} to destination path {SkillPath}", skillName, destinationPath); + Console.Write("Installing skill `{0}` to `{1}`...", skillName, destinationPath); CopyFilesRecursive(skillSourceDirectory, destination); + + Console.WriteLine(" Done."); } } + static SkillTarget Convention(string agent) => + new(global => Path.Combine( + global ? UserProfile : Environment.CurrentDirectory, + $".{agent}", + "skills")); + static string UserProfile => Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); static void CopyFilesRecursive(string source, string destination) @@ -60,4 +86,6 @@ static void CopyFilesRecursive(string source, string destination) CopyFilesRecursive(directory, Path.Combine(destination, Path.GetFileName(directory))); } } + + sealed record SkillTarget(Func ResolveSkillsDirectory); } \ No newline at end of file diff --git a/test/SeqCli.EndToEnd/Mcp/McpInstallTestCase.cs b/test/SeqCli.EndToEnd/Mcp/McpInstallTestCase.cs index a099e9c3..aa39ab3a 100644 --- a/test/SeqCli.EndToEnd/Mcp/McpInstallTestCase.cs +++ b/test/SeqCli.EndToEnd/Mcp/McpInstallTestCase.cs @@ -60,14 +60,76 @@ public Task ExecuteAsync(SeqConnection connection, ILogger logger, CliCommandRun Assert.Contains("\"seq\"", qwenConfig); Assert.False(File.Exists(Path.Combine(tmp.Path, ".qwen/mcp.json"))); - // VS Code has no supported user-global merge target. - var vscodeGlobalExit = runner.Exec("mcp install -a vscode --global", disconnected: true, workingDirectory: tmp.Path); - Assert.Equal(1, vscodeGlobalExit); - - var vscodeGlobalOutput = runner.LastRunProcess!.Output; - Assert.Contains("VS Code stores user-level MCP servers", vscodeGlobalOutput); - Assert.Contains("seqcli mcp install --agent vscode", vscodeGlobalOutput); - Assert.DoesNotContain("NotSupportedException", vscodeGlobalOutput); + // VS Code nests servers under a `servers` key in `.vscode/mcp.json`. + var vscodeExit = runner.Exec("mcp install -a vscode", disconnected: true, workingDirectory: tmp.Path); + Assert.Equal(0, vscodeExit); + + var vscodeConfig = File.ReadAllText(Path.Combine(tmp.Path, ".vscode/mcp.json")); + Assert.Contains("\"servers\"", vscodeConfig); + Assert.Contains("\"seq\"", vscodeConfig); + + // Gemini CLI reads `mcpServers` from `.gemini/settings.json`, not an `mcp.json`. + var geminiExit = runner.Exec("mcp install -a gemini", disconnected: true, workingDirectory: tmp.Path); + Assert.Equal(0, geminiExit); + + var geminiConfig = File.ReadAllText(Path.Combine(tmp.Path, ".gemini/settings.json")); + Assert.Contains("\"mcpServers\"", geminiConfig); + Assert.Contains("\"seq\"", geminiConfig); + Assert.False(File.Exists(Path.Combine(tmp.Path, ".gemini/mcp.json"))); + + // Zed embeds servers under `context_servers` in `.zed/settings.json`. + var zedExit = runner.Exec("mcp install -a zed", disconnected: true, workingDirectory: tmp.Path); + Assert.Equal(0, zedExit); + + var zedConfig = File.ReadAllText(Path.Combine(tmp.Path, ".zed/settings.json")); + Assert.Contains("\"context_servers\"", zedConfig); + Assert.Contains("\"seq\"", zedConfig); + + // Amazon Q Developer CLI reads a project `.amazonq/mcp.json`. + var amazonqExit = runner.Exec("mcp install -a amazonq", disconnected: true, workingDirectory: tmp.Path); + Assert.Equal(0, amazonqExit); + + var amazonqConfig = File.ReadAllText(Path.Combine(tmp.Path, ".amazonq/mcp.json")); + Assert.Contains("\"mcpServers\"", amazonqConfig); + Assert.Contains("\"seq\"", amazonqConfig); + + // Roo Code reads a project `.roo/mcp.json`... + var rooExit = runner.Exec("mcp install -a roo", disconnected: true, workingDirectory: tmp.Path); + Assert.Equal(0, rooExit); + Assert.True(File.Exists(Path.Combine(tmp.Path, ".roo/mcp.json"))); + + // ...but has no writable user-global target, so `--global` reports a clean error + // (and never leaks the exception type into the output). + var rooGlobalExit = runner.Exec("mcp install -a roo --global", disconnected: true, workingDirectory: tmp.Path); + Assert.Equal(1, rooGlobalExit); + + var rooGlobalOutput = runner.LastRunProcess!.Output; + Assert.Contains("extension storage", rooGlobalOutput); + Assert.DoesNotContain("NotSupportedException", rooGlobalOutput); + + // Windsurf is user-global only; a project install is rejected rather than writing + // an ignored `.windsurf/mcp.json`. + var windsurfExit = runner.Exec("mcp install -a windsurf", disconnected: true, workingDirectory: tmp.Path); + Assert.Equal(1, windsurfExit); + Assert.Contains("--global", runner.LastRunProcess!.Output); + Assert.False(File.Exists(Path.Combine(tmp.Path, ".windsurf/mcp.json"))); + + // Codex/Goose/Continue use TOML/YAML config seqcli can't edit; instead of writing + // an ignored JSON file, the command prints a copy-paste snippet and fails. + var codexExit = runner.Exec("mcp install -a codex", disconnected: true, workingDirectory: tmp.Path); + Assert.Equal(1, codexExit); + Assert.Contains("config.toml", runner.LastRunProcess!.Output); + Assert.False(Directory.Exists(Path.Combine(tmp.Path, ".codex"))); + + var gooseExit = runner.Exec("mcp install -a goose", disconnected: true, workingDirectory: tmp.Path); + Assert.Equal(1, gooseExit); + Assert.Contains("config.yaml", runner.LastRunProcess!.Output); + Assert.False(Directory.Exists(Path.Combine(tmp.Path, ".goose"))); + + var continueExit = runner.Exec("mcp install -a continue", disconnected: true, workingDirectory: tmp.Path); + Assert.Equal(1, continueExit); + Assert.Contains("YAML", runner.LastRunProcess!.Output); + Assert.False(File.Exists(Path.Combine(tmp.Path, ".continue/mcp.json"))); return Task.CompletedTask; } diff --git a/test/SeqCli.EndToEnd/SeqCli.EndToEnd.csproj b/test/SeqCli.EndToEnd/SeqCli.EndToEnd.csproj index 33dd29e3..988cbea1 100644 --- a/test/SeqCli.EndToEnd/SeqCli.EndToEnd.csproj +++ b/test/SeqCli.EndToEnd/SeqCli.EndToEnd.csproj @@ -7,6 +7,7 @@ false + diff --git a/test/SeqCli.EndToEnd/Skills/SkillsInstallTestCase.cs b/test/SeqCli.EndToEnd/Skills/SkillsInstallTestCase.cs index 831ad5b9..88871bc1 100644 --- a/test/SeqCli.EndToEnd/Skills/SkillsInstallTestCase.cs +++ b/test/SeqCli.EndToEnd/Skills/SkillsInstallTestCase.cs @@ -1,5 +1,6 @@ using System.IO; using System.Threading.Tasks; +using JetBrains.Annotations; using Seq.Api; using SeqCli.EndToEnd.Support; using Serilog; @@ -13,10 +14,38 @@ public Task ExecuteAsync(SeqConnection connection, ILogger logger, CliCommandRun { using var tmp = new TestDataFolder(); + // Convention fallback: an agent that isn't specially known installs into `.{agent}/skills`. var exit = runner.Exec("skills install -a test-agent", disconnected: true, workingDirectory: tmp.Path); Assert.Equal(0, exit); Assert.True(File.Exists(Path.Combine(tmp.Path, ".test-agent/skills/seq-search-and-query/SKILL.md"))); + // Claude Code reads `.claude/skills`, and refuses the portable `.agents` alias, so it must keep its own namespace. + var claudeExit = runner.Exec("skills install -a claude", disconnected: true, workingDirectory: tmp.Path); + Assert.Equal(0, claudeExit); + Assert.True(File.Exists(Path.Combine(tmp.Path, ".claude/skills/seq-search-and-query/SKILL.md"))); + + // Codex has no `.codex` skills dir; its project skills live in the portable `.agents/skills`. + var codexExit = runner.Exec("skills install -a codex", disconnected: true, workingDirectory: tmp.Path); + Assert.Equal(0, codexExit); + Assert.True(File.Exists(Path.Combine(tmp.Path, ".agents/skills/seq-search-and-query/SKILL.md"))); + Assert.False(Directory.Exists(Path.Combine(tmp.Path, ".codex"))); + + // GitHub Copilot / VS Code read workspace skills from `.github/skills`, not `.copilot/skills`. + var copilotExit = runner.Exec("skills install -a copilot", disconnected: true, workingDirectory: tmp.Path); + Assert.Equal(0, copilotExit); + Assert.True(File.Exists(Path.Combine(tmp.Path, ".github/skills/seq-search-and-query/SKILL.md"))); + Assert.False(Directory.Exists(Path.Combine(tmp.Path, ".copilot"))); + + // `github` is an alias for the same Copilot workspace location. + var githubExit = runner.Exec("skills install -a github", disconnected: true, workingDirectory: tmp.Path); + Assert.Equal(0, githubExit); + Assert.True(File.Exists(Path.Combine(tmp.Path, ".github/skills/seq-search-and-query/SKILL.md"))); + + // Goose uses the `agents` convention. + var gooseExit = runner.Exec("skills install -a goose", disconnected: true, workingDirectory: tmp.Path); + Assert.Equal(0, gooseExit); + Assert.True(File.Exists(Path.Combine(tmp.Path, ".agents/skills/seq-search-and-query/SKILL.md"))); + return Task.CompletedTask; } } \ No newline at end of file diff --git a/test/SeqCli.EndToEnd/Support/ICliTestCase.cs b/test/SeqCli.EndToEnd/Support/ICliTestCase.cs index 5697c2b8..849bb24f 100644 --- a/test/SeqCli.EndToEnd/Support/ICliTestCase.cs +++ b/test/SeqCli.EndToEnd/Support/ICliTestCase.cs @@ -1,10 +1,12 @@ using System.Threading.Tasks; +using JetBrains.Annotations; using Seq.Api; using Serilog; namespace SeqCli.EndToEnd.Support; +[UsedImplicitly(ImplicitUseTargetFlags.WithInheritors)] interface ICliTestCase { Task ExecuteAsync(SeqConnection connection, ILogger logger, CliCommandRunner runner); -} \ No newline at end of file +}