Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions src/SeqCli/Cli/Commands/Skills/InstallCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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`",
Expand Down
90 changes: 74 additions & 16 deletions src/SeqCli/Mcp/McpServerInstaller.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, AgentTarget> KnownAgents =
new Dictionary<string, AgentTarget>
{
// 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<string, string> AgentAliases =
new Dictionary<string, string>
{
["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);

Expand Down Expand Up @@ -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(
Expand All @@ -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")
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On OSX this is where Environment.SpecialFolder.ApplicationData already points, so could probably just special case Linux here.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missed this comment - thanks, will sort that out on another pass 👍

: Path.Combine(XdgConfigHome, "Code", "User");

sealed record AgentTarget(Func<bool, string> ResolvePath, string ServerMapKey);
}
38 changes: 33 additions & 5 deletions src/SeqCli/Skills/SkillInstaller.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,21 +13,39 @@
// limitations under the License.

using System;
using System.Collections.Generic;
using System.IO;
using Serilog;

namespace SeqCli.Skills;

static class SkillInstaller
{
static readonly IReadOnlyDictionary<string, SkillTarget> KnownAgents =
new Dictionary<string, SkillTarget>
{
["copilot"] = new(global => global
? Path.Combine(UserProfile, ".copilot", "skills")
: Path.Combine(Environment.CurrentDirectory, ".github", "skills")),
};

static readonly IReadOnlyDictionary<string, string> AgentAliases =
new Dictionary<string, string>
{
["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);

Expand All @@ -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)
Expand All @@ -60,4 +86,6 @@ static void CopyFilesRecursive(string source, string destination)
CopyFilesRecursive(directory, Path.Combine(destination, Path.GetFileName(directory)));
}
}

sealed record SkillTarget(Func<bool, string> ResolveSkillsDirectory);
}
78 changes: 70 additions & 8 deletions test/SeqCli.EndToEnd/Mcp/McpInstallTestCase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
1 change: 1 addition & 0 deletions test/SeqCli.EndToEnd/SeqCli.EndToEnd.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
<IsTestingPlatformApplication>false</IsTestingPlatformApplication>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="JetBrains.Annotations" Version="2025.2.4" />
<PackageReference Include="xunit" Version="2.9.3" />
</ItemGroup>
<ItemGroup>
Expand Down
29 changes: 29 additions & 0 deletions test/SeqCli.EndToEnd/Skills/SkillsInstallTestCase.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using System.IO;
using System.Threading.Tasks;
using JetBrains.Annotations;
using Seq.Api;
using SeqCli.EndToEnd.Support;
using Serilog;
Expand All @@ -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;
}
}
4 changes: 3 additions & 1 deletion test/SeqCli.EndToEnd/Support/ICliTestCase.cs
Original file line number Diff line number Diff line change
@@ -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);
}
}
Loading