Skip to content

Commit 1921d1b

Browse files
author
Nicholas Blumhardt
committed
Extend MCP and skills install commands to cover more agents and correct some existing ones.
Assisted-by: Claude Opus 4.6
1 parent 5e1cde9 commit 1921d1b

5 files changed

Lines changed: 220 additions & 23 deletions

File tree

src/SeqCli/Cli/Commands/Skills/InstallCommand.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,9 +29,9 @@ public InstallCommand()
2929
{
3030
Options.Add(
3131
"g|global",
32-
"Install skills globally, to `~/.{agent}/skills`; the default is to install locally, in `./{agent}/skills`",
32+
"Install skills to the agent's user-level directory (e.g. `~/.{agent}/skills`); the default is to install locally, in `./.{agent}/skills`",
3333
_ => _global = true);
34-
34+
3535
Options.Add(
3636
"a=|agent=",
3737
"The agent name to install skills for; the default is the generic name `agents`",

src/SeqCli/Mcp/McpServerInstaller.cs

Lines changed: 76 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,11 @@ static class McpServerInstaller
2626

2727
// Agents whose MCP config location or shape diverges from the common
2828
// `.{agent}/mcp.json` + `mcpServers` convention. Anything not listed here -
29-
// including the default `agents` name and any unknown agent - uses the
30-
// convention (see `Convention`), so adding support for a conformant agent
31-
// requires no change at all, and a divergent one is a single entry here.
29+
// including the default `agents` name, Cursor, and any unknown agent - uses
30+
// the convention (see `Convention`), so adding support for a conformant agent
31+
// requires no change at all, and a divergent one is a single entry here. Agents
32+
// whose config is a format we can't safely edit (TOML/YAML) are listed via
33+
// `Unsupported` so the user gets a copy-paste snippet instead of an ignored file.
3234
static readonly IReadOnlyDictionary<string, AgentTarget> KnownAgents =
3335
new Dictionary<string, AgentTarget>
3436
{
@@ -40,20 +42,22 @@ static class McpServerInstaller
4042
: Path.Combine(Environment.CurrentDirectory, ".mcp.json"),
4143
"mcpServers"),
4244

43-
// Windsurf keeps a single user-global config under `~/.codeium`.
45+
// Windsurf only reads a single user-global config under `~/.codeium`; it has
46+
// no project-level MCP file, so a project install would be silently ignored.
4447
["windsurf"] = new(
4548
global => global
4649
? Path.Combine(UserProfile, ".codeium", "windsurf", "mcp_config.json")
47-
: Path.Combine(Environment.CurrentDirectory, ".windsurf", "mcp.json"),
50+
: throw new NotSupportedException(
51+
"Windsurf only supports a user-global MCP config; re-run with `--global` (seqcli mcp install --global --agent windsurf)."),
4852
"mcpServers"),
4953

5054
// VS Code nests servers under a `servers` key. Project config lives in
51-
// `.vscode/mcp.json`; the user-global equivalent lives inside `settings.json`,
52-
// which is a different merge target and isn't supported here yet.
55+
// `.vscode/mcp.json`; the user-global equivalent is a `mcp.json` in the
56+
// VS Code user directory (`%APPDATA%\Code\User` on Windows, `~/Library/
57+
// Application Support/Code/User` on macOS, `$XDG_CONFIG_HOME/Code/User` otherwise).
5358
["vscode"] = new(
5459
global => global
55-
? throw new NotSupportedException(
56-
"VS Code stores user-level MCP servers in settings.json; install into a project with `seqcli mcp install --agent vscode` instead.")
60+
? Path.Combine(VsCodeUserDir, "mcp.json")
5761
: Path.Combine(Environment.CurrentDirectory, ".vscode", "mcp.json"),
5862
"servers"),
5963

@@ -65,6 +69,51 @@ static class McpServerInstaller
6569
".qwen",
6670
"settings.json"),
6771
"mcpServers"),
72+
73+
// Gemini CLI mirrors Qwen Code: `mcpServers` inside `settings.json` under `.gemini`.
74+
["gemini"] = new(
75+
global => Path.Combine(
76+
global ? UserProfile : Environment.CurrentDirectory,
77+
".gemini",
78+
"settings.json"),
79+
"mcpServers"),
80+
81+
// Zed embeds servers in its `settings.json` under a `context_servers` key
82+
// (project `.zed/settings.json`; user-global `$XDG_CONFIG_HOME/zed/settings.json`).
83+
["zed"] = new(
84+
global => global
85+
? Path.Combine(XdgConfigHome, "zed", "settings.json")
86+
: Path.Combine(Environment.CurrentDirectory, ".zed", "settings.json"),
87+
"context_servers"),
88+
89+
// Amazon Q Developer CLI uses a standalone `mcp.json`: `.amazonq` per-project,
90+
// but `~/.aws/amazonq` for the user-global file.
91+
["amazonq"] = new(
92+
global => global
93+
? Path.Combine(UserProfile, ".aws", "amazonq", "mcp.json")
94+
: Path.Combine(Environment.CurrentDirectory, ".amazonq", "mcp.json"),
95+
"mcpServers"),
96+
97+
// Roo Code reads a project `.roo/mcp.json`; its user-global store lives in
98+
// VS Code extension storage, whose path is publisher/platform-specific.
99+
["roo"] = new(
100+
global => global
101+
? throw new NotSupportedException(
102+
"Roo Code stores user-global MCP servers in VS Code extension storage; install into a project instead (seqcli mcp install --agent roo).")
103+
: Path.Combine(Environment.CurrentDirectory, ".roo", "mcp.json"),
104+
"mcpServers"),
105+
106+
// Codex, Goose, and Continue store MCP config in TOML/YAML that seqcli can't
107+
// safely edit, so we print the exact config to add by hand rather than writing
108+
// a JSON file the agent would ignore.
109+
["codex"] = Unsupported(
110+
"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\"]"),
111+
112+
["goose"] = Unsupported(
113+
"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"),
114+
115+
["continue"] = Unsupported(
116+
"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"),
68117
};
69118

70119
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
104153
Log.Information("Installed Seq MCP server for {Agent} to {Path}", agent, path);
105154
}
106155

156+
// For agents whose config format we can't write, resolving any path throws with a
157+
// copy-paste snippet; the command runner turns this into a clean exit-1 message.
158+
static AgentTarget Unsupported(string message) =>
159+
new(_ => throw new NotSupportedException(message), "mcpServers");
160+
107161
static AgentTarget Convention(string agent) =>
108162
new(
109163
global => Path.Combine(
@@ -114,5 +168,18 @@ static AgentTarget Convention(string agent) =>
114168

115169
static string UserProfile => Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
116170

171+
static string XdgConfigHome =>
172+
Environment.GetEnvironmentVariable("XDG_CONFIG_HOME") is { Length: > 0 } configHome
173+
? configHome
174+
: Path.Combine(UserProfile, ".config");
175+
176+
// VS Code keeps per-user data in an OS-specific directory.
177+
static string VsCodeUserDir =>
178+
OperatingSystem.IsWindows()
179+
? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "Code", "User")
180+
: OperatingSystem.IsMacOS()
181+
? Path.Combine(UserProfile, "Library", "Application Support", "Code", "User")
182+
: Path.Combine(XdgConfigHome, "Code", "User");
183+
117184
sealed record AgentTarget(Func<bool, string> ResolvePath, string ServerMapKey);
118185
}

src/SeqCli/Skills/SkillInstaller.cs

Lines changed: 43 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,21 +13,52 @@
1313
// limitations under the License.
1414

1515
using System;
16+
using System.Collections.Generic;
1617
using System.IO;
1718
using Serilog;
1819

1920
namespace SeqCli.Skills;
2021

2122
static class SkillInstaller
2223
{
24+
// Agents whose skills directory diverges from the common `.{agent}/skills` convention.
25+
// Anything not listed here - including the default `agents` name, Claude Code, Gemini CLI,
26+
// Cursor, Junie, Kiro, and any unknown agent - uses the convention (see `Convention`), so
27+
// a conformant agent requires no change at all and a divergent one is a single entry here.
28+
static readonly IReadOnlyDictionary<string, SkillTarget> KnownAgents =
29+
new Dictionary<string, SkillTarget>
30+
{
31+
// Codex reads skills only from `.agents/skills` (repo) and `~/.agents/skills`
32+
// (user); it has no `.codex` skills dir, so route both scopes to the portable alias.
33+
["codex"] = new(global => Path.Combine(
34+
global ? UserProfile : Environment.CurrentDirectory,
35+
".agents",
36+
"skills")),
37+
38+
// GitHub Copilot / VS Code read workspace skills from `.github/skills`, but the
39+
// user-global personal skills dir is `~/.copilot/skills` - the namespace differs by scope.
40+
["copilot"] = new(global => global
41+
? Path.Combine(UserProfile, ".copilot", "skills")
42+
: Path.Combine(Environment.CurrentDirectory, ".github", "skills")),
43+
44+
// `github` is the workspace dir name a user may reach for; same targets as copilot.
45+
["github"] = new(global => global
46+
? Path.Combine(UserProfile, ".copilot", "skills")
47+
: Path.Combine(Environment.CurrentDirectory, ".github", "skills")),
48+
49+
// Goose reads a project `.goose/skills`, but its user-global skills live under the
50+
// portable `~/.agents/skills` (not `~/.goose`).
51+
["goose"] = new(global => global
52+
? Path.Combine(UserProfile, ".agents", "skills")
53+
: Path.Combine(Environment.CurrentDirectory, ".goose", "skills")),
54+
};
55+
2356
public static void Install(string? agent, bool global)
2457
{
2558
agent ??= "agents";
2659

27-
var destinationPath = Path.Combine(
28-
global ? UserProfile : Environment.CurrentDirectory,
29-
$".{agent}",
30-
"skills");
60+
var target = KnownAgents.TryGetValue(agent, out var known) ? known : Convention(agent);
61+
var destinationPath = target.ResolveSkillsDirectory(global);
3162

3263
Log.Information("Installing skills to {SkillsPath}", destinationPath);
3364

@@ -44,6 +75,12 @@ public static void Install(string? agent, bool global)
4475
}
4576
}
4677

78+
static SkillTarget Convention(string agent) =>
79+
new(global => Path.Combine(
80+
global ? UserProfile : Environment.CurrentDirectory,
81+
$".{agent}",
82+
"skills"));
83+
4784
static string UserProfile => Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
4885

4986
static void CopyFilesRecursive(string source, string destination)
@@ -60,4 +97,6 @@ static void CopyFilesRecursive(string source, string destination)
6097
CopyFilesRecursive(directory, Path.Combine(destination, Path.GetFileName(directory)));
6198
}
6299
}
100+
101+
sealed record SkillTarget(Func<bool, string> ResolveSkillsDirectory);
63102
}

test/SeqCli.EndToEnd/Mcp/McpInstallTestCase.cs

Lines changed: 70 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -60,14 +60,76 @@ public Task ExecuteAsync(SeqConnection connection, ILogger logger, CliCommandRun
6060
Assert.Contains("\"seq\"", qwenConfig);
6161
Assert.False(File.Exists(Path.Combine(tmp.Path, ".qwen/mcp.json")));
6262

63-
// VS Code has no supported user-global merge target.
64-
var vscodeGlobalExit = runner.Exec("mcp install -a vscode --global", disconnected: true, workingDirectory: tmp.Path);
65-
Assert.Equal(1, vscodeGlobalExit);
66-
67-
var vscodeGlobalOutput = runner.LastRunProcess!.Output;
68-
Assert.Contains("VS Code stores user-level MCP servers", vscodeGlobalOutput);
69-
Assert.Contains("seqcli mcp install --agent vscode", vscodeGlobalOutput);
70-
Assert.DoesNotContain("NotSupportedException", vscodeGlobalOutput);
63+
// VS Code nests servers under a `servers` key in `.vscode/mcp.json`.
64+
var vscodeExit = runner.Exec("mcp install -a vscode", disconnected: true, workingDirectory: tmp.Path);
65+
Assert.Equal(0, vscodeExit);
66+
67+
var vscodeConfig = File.ReadAllText(Path.Combine(tmp.Path, ".vscode/mcp.json"));
68+
Assert.Contains("\"servers\"", vscodeConfig);
69+
Assert.Contains("\"seq\"", vscodeConfig);
70+
71+
// Gemini CLI reads `mcpServers` from `.gemini/settings.json`, not an `mcp.json`.
72+
var geminiExit = runner.Exec("mcp install -a gemini", disconnected: true, workingDirectory: tmp.Path);
73+
Assert.Equal(0, geminiExit);
74+
75+
var geminiConfig = File.ReadAllText(Path.Combine(tmp.Path, ".gemini/settings.json"));
76+
Assert.Contains("\"mcpServers\"", geminiConfig);
77+
Assert.Contains("\"seq\"", geminiConfig);
78+
Assert.False(File.Exists(Path.Combine(tmp.Path, ".gemini/mcp.json")));
79+
80+
// Zed embeds servers under `context_servers` in `.zed/settings.json`.
81+
var zedExit = runner.Exec("mcp install -a zed", disconnected: true, workingDirectory: tmp.Path);
82+
Assert.Equal(0, zedExit);
83+
84+
var zedConfig = File.ReadAllText(Path.Combine(tmp.Path, ".zed/settings.json"));
85+
Assert.Contains("\"context_servers\"", zedConfig);
86+
Assert.Contains("\"seq\"", zedConfig);
87+
88+
// Amazon Q Developer CLI reads a project `.amazonq/mcp.json`.
89+
var amazonqExit = runner.Exec("mcp install -a amazonq", disconnected: true, workingDirectory: tmp.Path);
90+
Assert.Equal(0, amazonqExit);
91+
92+
var amazonqConfig = File.ReadAllText(Path.Combine(tmp.Path, ".amazonq/mcp.json"));
93+
Assert.Contains("\"mcpServers\"", amazonqConfig);
94+
Assert.Contains("\"seq\"", amazonqConfig);
95+
96+
// Roo Code reads a project `.roo/mcp.json`...
97+
var rooExit = runner.Exec("mcp install -a roo", disconnected: true, workingDirectory: tmp.Path);
98+
Assert.Equal(0, rooExit);
99+
Assert.True(File.Exists(Path.Combine(tmp.Path, ".roo/mcp.json")));
100+
101+
// ...but has no writable user-global target, so `--global` reports a clean error
102+
// (and never leaks the exception type into the output).
103+
var rooGlobalExit = runner.Exec("mcp install -a roo --global", disconnected: true, workingDirectory: tmp.Path);
104+
Assert.Equal(1, rooGlobalExit);
105+
106+
var rooGlobalOutput = runner.LastRunProcess!.Output;
107+
Assert.Contains("extension storage", rooGlobalOutput);
108+
Assert.DoesNotContain("NotSupportedException", rooGlobalOutput);
109+
110+
// Windsurf is user-global only; a project install is rejected rather than writing
111+
// an ignored `.windsurf/mcp.json`.
112+
var windsurfExit = runner.Exec("mcp install -a windsurf", disconnected: true, workingDirectory: tmp.Path);
113+
Assert.Equal(1, windsurfExit);
114+
Assert.Contains("--global", runner.LastRunProcess!.Output);
115+
Assert.False(File.Exists(Path.Combine(tmp.Path, ".windsurf/mcp.json")));
116+
117+
// Codex/Goose/Continue use TOML/YAML config seqcli can't edit; instead of writing
118+
// an ignored JSON file, the command prints a copy-paste snippet and fails.
119+
var codexExit = runner.Exec("mcp install -a codex", disconnected: true, workingDirectory: tmp.Path);
120+
Assert.Equal(1, codexExit);
121+
Assert.Contains("config.toml", runner.LastRunProcess!.Output);
122+
Assert.False(Directory.Exists(Path.Combine(tmp.Path, ".codex")));
123+
124+
var gooseExit = runner.Exec("mcp install -a goose", disconnected: true, workingDirectory: tmp.Path);
125+
Assert.Equal(1, gooseExit);
126+
Assert.Contains("config.yaml", runner.LastRunProcess!.Output);
127+
Assert.False(Directory.Exists(Path.Combine(tmp.Path, ".goose")));
128+
129+
var continueExit = runner.Exec("mcp install -a continue", disconnected: true, workingDirectory: tmp.Path);
130+
Assert.Equal(1, continueExit);
131+
Assert.Contains("YAML", runner.LastRunProcess!.Output);
132+
Assert.False(File.Exists(Path.Combine(tmp.Path, ".continue/mcp.json")));
71133

72134
return Task.CompletedTask;
73135
}

test/SeqCli.EndToEnd/Skills/SkillsInstallTestCase.cs

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,39 @@ public Task ExecuteAsync(SeqConnection connection, ILogger logger, CliCommandRun
1313
{
1414
using var tmp = new TestDataFolder();
1515

16+
// Convention fallback: an agent that isn't specially known installs into `.{agent}/skills`.
1617
var exit = runner.Exec("skills install -a test-agent", disconnected: true, workingDirectory: tmp.Path);
1718
Assert.Equal(0, exit);
1819
Assert.True(File.Exists(Path.Combine(tmp.Path, ".test-agent/skills/seq-search-and-query/SKILL.md")));
1920

21+
// Conformant agents stay on the convention: Claude Code reads `.claude/skills`, and it
22+
// refuses the portable `.agents` alias, so it must keep its own namespace.
23+
var claudeExit = runner.Exec("skills install -a claude", disconnected: true, workingDirectory: tmp.Path);
24+
Assert.Equal(0, claudeExit);
25+
Assert.True(File.Exists(Path.Combine(tmp.Path, ".claude/skills/seq-search-and-query/SKILL.md")));
26+
27+
// Codex has no `.codex` skills dir; its project skills live in the portable `.agents/skills`.
28+
var codexExit = runner.Exec("skills install -a codex", disconnected: true, workingDirectory: tmp.Path);
29+
Assert.Equal(0, codexExit);
30+
Assert.True(File.Exists(Path.Combine(tmp.Path, ".agents/skills/seq-search-and-query/SKILL.md")));
31+
Assert.False(Directory.Exists(Path.Combine(tmp.Path, ".codex")));
32+
33+
// GitHub Copilot / VS Code read workspace skills from `.github/skills`, not `.copilot/skills`.
34+
var copilotExit = runner.Exec("skills install -a copilot", disconnected: true, workingDirectory: tmp.Path);
35+
Assert.Equal(0, copilotExit);
36+
Assert.True(File.Exists(Path.Combine(tmp.Path, ".github/skills/seq-search-and-query/SKILL.md")));
37+
Assert.False(Directory.Exists(Path.Combine(tmp.Path, ".copilot")));
38+
39+
// `github` is an alias for the same Copilot workspace location.
40+
var githubExit = runner.Exec("skills install -a github", disconnected: true, workingDirectory: tmp.Path);
41+
Assert.Equal(0, githubExit);
42+
Assert.True(File.Exists(Path.Combine(tmp.Path, ".github/skills/seq-search-and-query/SKILL.md")));
43+
44+
// Goose reads a project `.goose/skills`.
45+
var gooseExit = runner.Exec("skills install -a goose", disconnected: true, workingDirectory: tmp.Path);
46+
Assert.Equal(0, gooseExit);
47+
Assert.True(File.Exists(Path.Combine(tmp.Path, ".goose/skills/seq-search-and-query/SKILL.md")));
48+
2049
return Task.CompletedTask;
2150
}
2251
}

0 commit comments

Comments
 (0)