Skip to content

Commit 0e78d6f

Browse files
authored
Merge pull request #465 from nblumhardt-ro/updated-agent-installers
Extend MCP and skills install commands to cover more agents and correct some existing ones
2 parents 5e1cde9 + 67c8d74 commit 0e78d6f

7 files changed

Lines changed: 212 additions & 32 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: 74 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -24,53 +24,91 @@ static class McpServerInstaller
2424
{
2525
const string ServerName = "seq";
2626

27-
// Agents whose MCP config location or shape diverges from the common
28-
// `.{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.
3227
static readonly IReadOnlyDictionary<string, AgentTarget> KnownAgents =
3328
new Dictionary<string, AgentTarget>
3429
{
35-
// Claude Code reads project servers from a root `.mcp.json`, and
36-
// user-global servers from `~/.claude.json`.
3730
["claude"] = new(
3831
global => global
3932
? Path.Combine(UserProfile, ".claude.json")
4033
: Path.Combine(Environment.CurrentDirectory, ".mcp.json"),
4134
"mcpServers"),
4235

43-
// Windsurf keeps a single user-global config under `~/.codeium`.
4436
["windsurf"] = new(
4537
global => global
4638
? Path.Combine(UserProfile, ".codeium", "windsurf", "mcp_config.json")
47-
: Path.Combine(Environment.CurrentDirectory, ".windsurf", "mcp.json"),
39+
: throw new NotSupportedException(
40+
"Windsurf only supports a user-global MCP config; re-run with `--global`."),
4841
"mcpServers"),
4942

50-
// 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.
5343
["vscode"] = new(
5444
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.")
45+
? Path.Combine(VsCodeUserDir, "mcp.json")
5746
: Path.Combine(Environment.CurrentDirectory, ".vscode", "mcp.json"),
5847
"servers"),
48+
49+
["copilot"] = new(
50+
global => global
51+
? Path.Combine(UserProfile, ".copilot", "mcp-config.json")
52+
: throw new NotSupportedException(
53+
"GitHub Copilot only supports a user-global MCP config; re-run with `--global`."),
54+
"mcpServers"),
5955

60-
// Qwen Code reads MCP servers from the `mcpServers` key of its `settings.json`,
61-
// both user-global (`~/.qwen`) and per-project (`.qwen`) - not a standalone `mcp.json`.
6256
["qwen"] = new(
6357
global => Path.Combine(
6458
global ? UserProfile : Environment.CurrentDirectory,
6559
".qwen",
6660
"settings.json"),
6761
"mcpServers"),
62+
63+
["gemini"] = new(
64+
global => Path.Combine(
65+
global ? UserProfile : Environment.CurrentDirectory,
66+
".gemini",
67+
"settings.json"),
68+
"mcpServers"),
69+
70+
["zed"] = new(
71+
global => global
72+
? Path.Combine(XdgConfigHome, "zed", "settings.json")
73+
: Path.Combine(Environment.CurrentDirectory, ".zed", "settings.json"),
74+
"context_servers"),
75+
76+
["amazonq"] = new(
77+
global => global
78+
? Path.Combine(UserProfile, ".aws", "amazonq", "mcp.json")
79+
: Path.Combine(Environment.CurrentDirectory, ".amazonq", "mcp.json"),
80+
"mcpServers"),
81+
82+
["roo"] = new(
83+
global => global
84+
? throw new NotSupportedException(
85+
"Roo Code stores user-global MCP servers in VS Code extension storage; install into a project instead.")
86+
: Path.Combine(Environment.CurrentDirectory, ".roo", "mcp.json"),
87+
"mcpServers"),
88+
89+
["codex"] = Unsupported(
90+
"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\"]"),
91+
92+
["goose"] = Unsupported(
93+
"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"),
94+
95+
["continue"] = Unsupported(
96+
"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"),
97+
};
98+
99+
static readonly IReadOnlyDictionary<string, string> AgentAliases =
100+
new Dictionary<string, string>
101+
{
102+
["github"] = "copilot"
68103
};
69104

70105
public static void Install(string? agent, bool global, string? profileName = null)
71106
{
72107
agent ??= "agents";
73108

109+
if (AgentAliases.TryGetValue(agent, out var alias))
110+
agent = alias;
111+
74112
var target = KnownAgents.TryGetValue(agent, out var known) ? known : Convention(agent);
75113
var path = target.ResolvePath(global);
76114

@@ -98,12 +136,19 @@ public static void Install(string? agent, bool global, string? profileName = nul
98136
["args"] = args,
99137
};
100138

139+
Console.Write("Installing MCP server to `{0}`...", path);
140+
101141
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
102142
File.WriteAllText(path, root.ToString(Newtonsoft.Json.Formatting.Indented));
103143

144+
Console.WriteLine(" Done.");
145+
104146
Log.Information("Installed Seq MCP server for {Agent} to {Path}", agent, path);
105147
}
106148

149+
static AgentTarget Unsupported(string message) =>
150+
new(_ => throw new NotSupportedException(message), "mcpServers");
151+
107152
static AgentTarget Convention(string agent) =>
108153
new(
109154
global => Path.Combine(
@@ -114,5 +159,18 @@ static AgentTarget Convention(string agent) =>
114159

115160
static string UserProfile => Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
116161

162+
static string XdgConfigHome =>
163+
Environment.GetEnvironmentVariable("XDG_CONFIG_HOME") is { Length: > 0 } configHome
164+
? configHome
165+
: Path.Combine(UserProfile, ".config");
166+
167+
// VS Code keeps per-user data in an OS-specific directory.
168+
static string VsCodeUserDir =>
169+
OperatingSystem.IsWindows()
170+
? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "Code", "User")
171+
: OperatingSystem.IsMacOS()
172+
? Path.Combine(UserProfile, "Library", "Application Support", "Code", "User")
173+
: Path.Combine(XdgConfigHome, "Code", "User");
174+
117175
sealed record AgentTarget(Func<bool, string> ResolvePath, string ServerMapKey);
118176
}

src/SeqCli/Skills/SkillInstaller.cs

Lines changed: 33 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,21 +13,39 @@
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+
static readonly IReadOnlyDictionary<string, SkillTarget> KnownAgents =
25+
new Dictionary<string, SkillTarget>
26+
{
27+
["copilot"] = new(global => global
28+
? Path.Combine(UserProfile, ".copilot", "skills")
29+
: Path.Combine(Environment.CurrentDirectory, ".github", "skills")),
30+
};
31+
32+
static readonly IReadOnlyDictionary<string, string> AgentAliases =
33+
new Dictionary<string, string>
34+
{
35+
["goose"] = "agents",
36+
["github"] = "copilot",
37+
["codex"] = "agents"
38+
};
39+
2340
public static void Install(string? agent, bool global)
2441
{
2542
agent ??= "agents";
2643

27-
var destinationPath = Path.Combine(
28-
global ? UserProfile : Environment.CurrentDirectory,
29-
$".{agent}",
30-
"skills");
44+
if (AgentAliases.TryGetValue(agent, out var alias))
45+
agent = alias;
46+
47+
var target = KnownAgents.TryGetValue(agent, out var known) ? known : Convention(agent);
48+
var destinationPath = target.ResolveSkillsDirectory(global);
3149

3250
Log.Information("Installing skills to {SkillsPath}", destinationPath);
3351

@@ -38,12 +56,20 @@ public static void Install(string? agent, bool global)
3856
var skillName = Path.GetFileName(skillSourceDirectory);
3957
var destination = Path.Combine(destinationPath, skillName);
4058

41-
Log.Information("Installing skill {SkillName} to destination path {SkillPath}", skillName, destinationPath);
59+
Console.Write("Installing skill `{0}` to `{1}`...", skillName, destinationPath);
4260

4361
CopyFilesRecursive(skillSourceDirectory, destination);
62+
63+
Console.WriteLine(" Done.");
4464
}
4565
}
4666

67+
static SkillTarget Convention(string agent) =>
68+
new(global => Path.Combine(
69+
global ? UserProfile : Environment.CurrentDirectory,
70+
$".{agent}",
71+
"skills"));
72+
4773
static string UserProfile => Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
4874

4975
static void CopyFilesRecursive(string source, string destination)
@@ -60,4 +86,6 @@ static void CopyFilesRecursive(string source, string destination)
6086
CopyFilesRecursive(directory, Path.Combine(destination, Path.GetFileName(directory)));
6187
}
6288
}
89+
90+
sealed record SkillTarget(Func<bool, string> ResolveSkillsDirectory);
6391
}

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/SeqCli.EndToEnd.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
<IsTestingPlatformApplication>false</IsTestingPlatformApplication>
88
</PropertyGroup>
99
<ItemGroup>
10+
<PackageReference Include="JetBrains.Annotations" Version="2025.2.4" />
1011
<PackageReference Include="xunit" Version="2.9.3" />
1112
</ItemGroup>
1213
<ItemGroup>

test/SeqCli.EndToEnd/Skills/SkillsInstallTestCase.cs

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
using System.IO;
22
using System.Threading.Tasks;
3+
using JetBrains.Annotations;
34
using Seq.Api;
45
using SeqCli.EndToEnd.Support;
56
using Serilog;
@@ -13,10 +14,38 @@ public Task ExecuteAsync(SeqConnection connection, ILogger logger, CliCommandRun
1314
{
1415
using var tmp = new TestDataFolder();
1516

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

22+
// Claude Code reads `.claude/skills`, and 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 uses the `agents` convention.
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, ".agents/skills/seq-search-and-query/SKILL.md")));
48+
2049
return Task.CompletedTask;
2150
}
2251
}
Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
using System.Threading.Tasks;
2+
using JetBrains.Annotations;
23
using Seq.Api;
34
using Serilog;
45

56
namespace SeqCli.EndToEnd.Support;
67

8+
[UsedImplicitly(ImplicitUseTargetFlags.WithInheritors)]
79
interface ICliTestCase
810
{
911
Task ExecuteAsync(SeqConnection connection, ILogger logger, CliCommandRunner runner);
10-
}
12+
}

0 commit comments

Comments
 (0)