From 1921d1b335aade11db04d10ce9da7ad4762dfb9b Mon Sep 17 00:00:00 2001 From: Nicholas Blumhardt Date: Thu, 4 Jun 2026 05:35:20 +1000 Subject: [PATCH 1/3] Extend MCP and skills install commands to cover more agents and correct some existing ones. Assisted-by: Claude Opus 4.6 --- .../Cli/Commands/Skills/InstallCommand.cs | 4 +- src/SeqCli/Mcp/McpServerInstaller.cs | 85 +++++++++++++++++-- src/SeqCli/Skills/SkillInstaller.cs | 47 +++++++++- .../SeqCli.EndToEnd/Mcp/McpInstallTestCase.cs | 78 +++++++++++++++-- .../Skills/SkillsInstallTestCase.cs | 29 +++++++ 5 files changed, 220 insertions(+), 23 deletions(-) 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..7610010e 100644 --- a/src/SeqCli/Mcp/McpServerInstaller.cs +++ b/src/SeqCli/Mcp/McpServerInstaller.cs @@ -26,9 +26,11 @@ static class McpServerInstaller // 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. + // including the default `agents` name, Cursor, 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. Agents + // whose config is a format we can't safely edit (TOML/YAML) are listed via + // `Unsupported` so the user gets a copy-paste snippet instead of an ignored file. static readonly IReadOnlyDictionary KnownAgents = new Dictionary { @@ -40,20 +42,22 @@ static class McpServerInstaller : Path.Combine(Environment.CurrentDirectory, ".mcp.json"), "mcpServers"), - // Windsurf keeps a single user-global config under `~/.codeium`. + // Windsurf only reads a single user-global config under `~/.codeium`; it has + // no project-level MCP file, so a project install would be silently ignored. ["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` (seqcli mcp install --global --agent windsurf)."), "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/mcp.json`; the user-global equivalent is a `mcp.json` in the + // VS Code user directory (`%APPDATA%\Code\User` on Windows, `~/Library/ + // Application Support/Code/User` on macOS, `$XDG_CONFIG_HOME/Code/User` otherwise). ["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"), @@ -65,6 +69,51 @@ static class McpServerInstaller ".qwen", "settings.json"), "mcpServers"), + + // Gemini CLI mirrors Qwen Code: `mcpServers` inside `settings.json` under `.gemini`. + ["gemini"] = new( + global => Path.Combine( + global ? UserProfile : Environment.CurrentDirectory, + ".gemini", + "settings.json"), + "mcpServers"), + + // Zed embeds servers in its `settings.json` under a `context_servers` key + // (project `.zed/settings.json`; user-global `$XDG_CONFIG_HOME/zed/settings.json`). + ["zed"] = new( + global => global + ? Path.Combine(XdgConfigHome, "zed", "settings.json") + : Path.Combine(Environment.CurrentDirectory, ".zed", "settings.json"), + "context_servers"), + + // Amazon Q Developer CLI uses a standalone `mcp.json`: `.amazonq` per-project, + // but `~/.aws/amazonq` for the user-global file. + ["amazonq"] = new( + global => global + ? Path.Combine(UserProfile, ".aws", "amazonq", "mcp.json") + : Path.Combine(Environment.CurrentDirectory, ".amazonq", "mcp.json"), + "mcpServers"), + + // Roo Code reads a project `.roo/mcp.json`; its user-global store lives in + // VS Code extension storage, whose path is publisher/platform-specific. + ["roo"] = new( + global => global + ? throw new NotSupportedException( + "Roo Code stores user-global MCP servers in VS Code extension storage; install into a project instead (seqcli mcp install --agent roo).") + : Path.Combine(Environment.CurrentDirectory, ".roo", "mcp.json"), + "mcpServers"), + + // Codex, Goose, and Continue store MCP config in TOML/YAML that seqcli can't + // safely edit, so we print the exact config to add by hand rather than writing + // a JSON file the agent would ignore. + ["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"), }; public static void Install(string? agent, bool global, string? profileName = null) @@ -104,6 +153,11 @@ public static void Install(string? agent, bool global, string? profileName = nul Log.Information("Installed Seq MCP server for {Agent} to {Path}", agent, path); } + // For agents whose config format we can't write, resolving any path throws with a + // copy-paste snippet; the command runner turns this into a clean exit-1 message. + static AgentTarget Unsupported(string message) => + new(_ => throw new NotSupportedException(message), "mcpServers"); + static AgentTarget Convention(string agent) => new( global => Path.Combine( @@ -114,5 +168,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..2bc4188f 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,44 @@ namespace SeqCli.Skills; static class SkillInstaller { + // Agents whose skills directory diverges from the common `.{agent}/skills` convention. + // Anything not listed here - including the default `agents` name, Claude Code, Gemini CLI, + // Cursor, Junie, Kiro, and any unknown agent - uses the convention (see `Convention`), so + // a conformant agent requires no change at all and a divergent one is a single entry here. + static readonly IReadOnlyDictionary KnownAgents = + new Dictionary + { + // Codex reads skills only from `.agents/skills` (repo) and `~/.agents/skills` + // (user); it has no `.codex` skills dir, so route both scopes to the portable alias. + ["codex"] = new(global => Path.Combine( + global ? UserProfile : Environment.CurrentDirectory, + ".agents", + "skills")), + + // GitHub Copilot / VS Code read workspace skills from `.github/skills`, but the + // user-global personal skills dir is `~/.copilot/skills` - the namespace differs by scope. + ["copilot"] = new(global => global + ? Path.Combine(UserProfile, ".copilot", "skills") + : Path.Combine(Environment.CurrentDirectory, ".github", "skills")), + + // `github` is the workspace dir name a user may reach for; same targets as copilot. + ["github"] = new(global => global + ? Path.Combine(UserProfile, ".copilot", "skills") + : Path.Combine(Environment.CurrentDirectory, ".github", "skills")), + + // Goose reads a project `.goose/skills`, but its user-global skills live under the + // portable `~/.agents/skills` (not `~/.goose`). + ["goose"] = new(global => global + ? Path.Combine(UserProfile, ".agents", "skills") + : Path.Combine(Environment.CurrentDirectory, ".goose", "skills")), + }; + public static void Install(string? agent, bool global) { agent ??= "agents"; - var destinationPath = Path.Combine( - global ? UserProfile : Environment.CurrentDirectory, - $".{agent}", - "skills"); + var target = KnownAgents.TryGetValue(agent, out var known) ? known : Convention(agent); + var destinationPath = target.ResolveSkillsDirectory(global); Log.Information("Installing skills to {SkillsPath}", destinationPath); @@ -44,6 +75,12 @@ public static void Install(string? agent, bool global) } } + 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 +97,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/Skills/SkillsInstallTestCase.cs b/test/SeqCli.EndToEnd/Skills/SkillsInstallTestCase.cs index 831ad5b9..0a62752d 100644 --- a/test/SeqCli.EndToEnd/Skills/SkillsInstallTestCase.cs +++ b/test/SeqCli.EndToEnd/Skills/SkillsInstallTestCase.cs @@ -13,10 +13,39 @@ 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"))); + // Conformant agents stay on the convention: Claude Code reads `.claude/skills`, and it + // 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 reads a project `.goose/skills`. + 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, ".goose/skills/seq-search-and-query/SKILL.md"))); + return Task.CompletedTask; } } \ No newline at end of file From e327c26c933186a89957d3c369374b2b6cbfcac3 Mon Sep 17 00:00:00 2001 From: Nicholas Blumhardt Date: Thu, 4 Jun 2026 09:27:21 +1000 Subject: [PATCH 2/3] Tidy up extended MCP/skill installers --- src/SeqCli/Mcp/McpServerInstaller.cs | 49 +++++++------------ src/SeqCli/Skills/SkillInstaller.cs | 33 ++++--------- test/SeqCli.EndToEnd/SeqCli.EndToEnd.csproj | 1 + .../Skills/SkillsInstallTestCase.cs | 8 +-- test/SeqCli.EndToEnd/Support/ICliTestCase.cs | 4 +- 5 files changed, 36 insertions(+), 59 deletions(-) diff --git a/src/SeqCli/Mcp/McpServerInstaller.cs b/src/SeqCli/Mcp/McpServerInstaller.cs index 7610010e..18e8f6a0 100644 --- a/src/SeqCli/Mcp/McpServerInstaller.cs +++ b/src/SeqCli/Mcp/McpServerInstaller.cs @@ -24,45 +24,35 @@ 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, Cursor, 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. Agents - // whose config is a format we can't safely edit (TOML/YAML) are listed via - // `Unsupported` so the user gets a copy-paste snippet instead of an ignored file. 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 only reads a single user-global config under `~/.codeium`; it has - // no project-level MCP file, so a project install would be silently ignored. ["windsurf"] = new( global => global ? Path.Combine(UserProfile, ".codeium", "windsurf", "mcp_config.json") : throw new NotSupportedException( - "Windsurf only supports a user-global MCP config; re-run with `--global` (seqcli mcp install --global --agent windsurf)."), + "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 is a `mcp.json` in the - // VS Code user directory (`%APPDATA%\Code\User` on Windows, `~/Library/ - // Application Support/Code/User` on macOS, `$XDG_CONFIG_HOME/Code/User` otherwise). ["vscode"] = new( global => global ? 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, @@ -70,7 +60,6 @@ static class McpServerInstaller "settings.json"), "mcpServers"), - // Gemini CLI mirrors Qwen Code: `mcpServers` inside `settings.json` under `.gemini`. ["gemini"] = new( global => Path.Combine( global ? UserProfile : Environment.CurrentDirectory, @@ -78,34 +67,25 @@ static class McpServerInstaller "settings.json"), "mcpServers"), - // Zed embeds servers in its `settings.json` under a `context_servers` key - // (project `.zed/settings.json`; user-global `$XDG_CONFIG_HOME/zed/settings.json`). ["zed"] = new( global => global ? Path.Combine(XdgConfigHome, "zed", "settings.json") : Path.Combine(Environment.CurrentDirectory, ".zed", "settings.json"), "context_servers"), - // Amazon Q Developer CLI uses a standalone `mcp.json`: `.amazonq` per-project, - // but `~/.aws/amazonq` for the user-global file. ["amazonq"] = new( global => global ? Path.Combine(UserProfile, ".aws", "amazonq", "mcp.json") : Path.Combine(Environment.CurrentDirectory, ".amazonq", "mcp.json"), "mcpServers"), - // Roo Code reads a project `.roo/mcp.json`; its user-global store lives in - // VS Code extension storage, whose path is publisher/platform-specific. ["roo"] = new( global => global ? throw new NotSupportedException( - "Roo Code stores user-global MCP servers in VS Code extension storage; install into a project instead (seqcli mcp install --agent roo).") + "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, Goose, and Continue store MCP config in TOML/YAML that seqcli can't - // safely edit, so we print the exact config to add by hand rather than writing - // a JSON file the agent would ignore. ["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\"]"), @@ -115,11 +95,20 @@ static class McpServerInstaller ["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); @@ -153,8 +142,6 @@ public static void Install(string? agent, bool global, string? profileName = nul Log.Information("Installed Seq MCP server for {Agent} to {Path}", agent, path); } - // For agents whose config format we can't write, resolving any path throws with a - // copy-paste snippet; the command runner turns this into a clean exit-1 message. static AgentTarget Unsupported(string message) => new(_ => throw new NotSupportedException(message), "mcpServers"); diff --git a/src/SeqCli/Skills/SkillInstaller.cs b/src/SeqCli/Skills/SkillInstaller.cs index 2bc4188f..2f62ede4 100644 --- a/src/SeqCli/Skills/SkillInstaller.cs +++ b/src/SeqCli/Skills/SkillInstaller.cs @@ -21,42 +21,29 @@ namespace SeqCli.Skills; static class SkillInstaller { - // Agents whose skills directory diverges from the common `.{agent}/skills` convention. - // Anything not listed here - including the default `agents` name, Claude Code, Gemini CLI, - // Cursor, Junie, Kiro, and any unknown agent - uses the convention (see `Convention`), so - // a conformant agent requires no change at all and a divergent one is a single entry here. static readonly IReadOnlyDictionary KnownAgents = new Dictionary { - // Codex reads skills only from `.agents/skills` (repo) and `~/.agents/skills` - // (user); it has no `.codex` skills dir, so route both scopes to the portable alias. - ["codex"] = new(global => Path.Combine( - global ? UserProfile : Environment.CurrentDirectory, - ".agents", - "skills")), - - // GitHub Copilot / VS Code read workspace skills from `.github/skills`, but the - // user-global personal skills dir is `~/.copilot/skills` - the namespace differs by scope. ["copilot"] = new(global => global ? Path.Combine(UserProfile, ".copilot", "skills") : Path.Combine(Environment.CurrentDirectory, ".github", "skills")), + }; - // `github` is the workspace dir name a user may reach for; same targets as copilot. - ["github"] = new(global => global - ? Path.Combine(UserProfile, ".copilot", "skills") - : Path.Combine(Environment.CurrentDirectory, ".github", "skills")), - - // Goose reads a project `.goose/skills`, but its user-global skills live under the - // portable `~/.agents/skills` (not `~/.goose`). - ["goose"] = new(global => global - ? Path.Combine(UserProfile, ".agents", "skills") - : Path.Combine(Environment.CurrentDirectory, ".goose", "skills")), + static readonly IReadOnlyDictionary AgentAliases = + new Dictionary + { + ["goose"] = "agents", + ["github"] = "copilot", + ["codex"] = "agents" }; public static void Install(string? agent, bool global) { agent ??= "agents"; + 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); 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 0a62752d..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; @@ -18,8 +19,7 @@ public Task ExecuteAsync(SeqConnection connection, ILogger logger, CliCommandRun Assert.Equal(0, exit); Assert.True(File.Exists(Path.Combine(tmp.Path, ".test-agent/skills/seq-search-and-query/SKILL.md"))); - // Conformant agents stay on the convention: Claude Code reads `.claude/skills`, and it - // refuses the portable `.agents` alias, so it must keep its own namespace. + // 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"))); @@ -41,10 +41,10 @@ public Task ExecuteAsync(SeqConnection connection, ILogger logger, CliCommandRun Assert.Equal(0, githubExit); Assert.True(File.Exists(Path.Combine(tmp.Path, ".github/skills/seq-search-and-query/SKILL.md"))); - // Goose reads a project `.goose/skills`. + // 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, ".goose/skills/seq-search-and-query/SKILL.md"))); + Assert.True(File.Exists(Path.Combine(tmp.Path, ".agents/skills/seq-search-and-query/SKILL.md"))); return Task.CompletedTask; } 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 +} From 67c8d74d41743c84cdb2bc2808a6458a5822f532 Mon Sep 17 00:00:00 2001 From: Nicholas Blumhardt Date: Thu, 4 Jun 2026 09:35:46 +1000 Subject: [PATCH 3/3] A little more feedback for the user --- src/SeqCli/Mcp/McpServerInstaller.cs | 4 ++++ src/SeqCli/Skills/SkillInstaller.cs | 4 +++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/SeqCli/Mcp/McpServerInstaller.cs b/src/SeqCli/Mcp/McpServerInstaller.cs index 18e8f6a0..52bc4596 100644 --- a/src/SeqCli/Mcp/McpServerInstaller.cs +++ b/src/SeqCli/Mcp/McpServerInstaller.cs @@ -136,9 +136,13 @@ 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); } diff --git a/src/SeqCli/Skills/SkillInstaller.cs b/src/SeqCli/Skills/SkillInstaller.cs index 2f62ede4..eec37480 100644 --- a/src/SeqCli/Skills/SkillInstaller.cs +++ b/src/SeqCli/Skills/SkillInstaller.cs @@ -56,9 +56,11 @@ 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."); } }