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
1,803 changes: 1,803 additions & 0 deletions docs/superpowers/plans/2026-04-10-multi-server-profiles.md

Large diffs are not rendered by default.

119 changes: 119 additions & 0 deletions docs/superpowers/specs/2026-04-10-multi-server-profiles-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
# Multi-Server Profiles

Support consultants (and other users) who work across multiple organizations, each with its own Kapacitor server instance.

## Problem

The CLI currently supports a single `server_url` in `~/.config/kapacitor/config.json`. A consultant working for multiple customers must manually reconfigure the CLI each time they switch contexts, risking sessions being sent to the wrong server.

## Design

### Config structure (v2)

The flat v1 config is replaced with a profile-based structure. Each profile holds the full set of config fields. A `profile_bindings` map ties repo paths to profiles for explicit overrides.

```json
{
"version": 2,
"active_profile": "default",
"profiles": {
"default": {
"server_url": "https://my-server.example.com",
"daemon": { "name": "my-machine", "max_agents": 2 },
"default_visibility": "org_public",
"update_check": true,
"excluded_repos": []
},
"contoso": {
"server_url": "https://contoso.kapacitor.io",
"daemon": { "name": "consulting-box", "max_agents": 1 },
"default_visibility": "private",
"update_check": true,
"excluded_repos": [],
"remotes": ["github.com/contoso/*", "github.com/contoso-labs/*"]
}
},
"profile_bindings": {
"/Users/alexey/dev/contoso/new-project": "contoso"
}
}
```

### Profile resolution chain

The CLI resolves which profile to use via a strict priority chain:

1. `--server-url` CLI flag — bypasses profiles entirely, uses the URL directly
2. `KAPACITOR_URL` env var — bypasses profiles entirely
3. `KAPACITOR_PROFILE` env var — explicit profile name for the shell session
4. `.kapacitor.json` in repo — committed, shared with team (see below)
5. Git remote match against profile `remotes` patterns
6. Repo path match in `profile_bindings` (written by `kapacitor use`)
7. `active_profile` global fallback (defaults to `"default"`)

Steps 1-2 return a server URL without a full profile. Steps 3-7 resolve to a named profile with full config.

`active_profile` is purely a fallback. It does not override repo-level signals (steps 4-6).

### Repo-level config (`.kapacitor.json`)

A file committed to the repo root so team members auto-route to the correct server:

```json
{
"profile": "contoso",
"server_url": "https://contoso.kapacitor.io"
}
```

Both fields are stored so the CLI can bootstrap a missing profile in the future.

Resolution logic:
- Local profile with matching name and URL exists: use it
- Profile name exists but URL doesn't match: warn (stale config)
- Profile name doesn't exist: prompt to run setup (future: auto-create from the URL)

### Git remote matching

Profile `remotes` are glob patterns matched against normalized repo remote URLs.

Normalization:
- Strip protocol (`https://`, `git@`, `ssh://`)
- Strip `.git` suffix
- Normalize SSH colon syntax (`github.com:org/repo` becomes `github.com/org/repo`)
- Strip auth tokens/usernames

So `https://github.com/contoso/foo.git`, `git@github.com:contoso/foo`, and `ssh://github.com/contoso/foo.git` all normalize to `github.com/contoso/foo`.

Conflict handling:
- Exactly one profile matches: use it
- Multiple profiles match: error listing the conflicting profiles, suggest `.kapacitor.json` to disambiguate
- No match: fall through to next resolution step

### Commands

- `kapacitor profile add <name> --server-url <url> [--remote "pattern"]` — create a new profile
- `kapacitor profile list` — show all profiles, mark which is active for the current context
- `kapacitor profile remove <name>` — delete a profile (cannot remove `default`)
- `kapacitor profile show [name]` — show effective config for a profile, or the currently resolved profile
- `kapacitor use <name> [--global]` — in a repo: write to `profile_bindings`; outside repo or with `--global`: set `active_profile`
- `kapacitor use <name> --save` — also write `.kapacitor.json` to the repo root

### Backward compatibility & migration

When the CLI reads `config.json`:
- No `version` field: v1 (old flat format). Migrate in-place:
- Move `server_url`, `daemon`, `default_visibility`, `update_check`, `excluded_repos` into `profiles.default`
- Set `active_profile: "default"`, `profile_bindings: {}`, `version: 2`
- Write back
- `version: 2`: use as-is

Code changes:
- Replace `AppConfig.ResolveServerUrl()` with a `ProfileResolver` that walks the resolution chain and returns the full resolved profile
- All existing call sites get identical behavior (resolve to `default` profile since no other profiles exist after migration)

The `setup` command:
- First run (no config): creates v2 config with `default` profile, same interactive flow as today
- Existing v1 config: migrates first, then edits `default` profile

A user who never creates a second profile sees no behavior change.
39 changes: 17 additions & 22 deletions src/kapacitor/Commands/ConfigCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,17 +22,8 @@ public static async Task<int> HandleAsync(string[] args) {
}

static async Task<int> Show() {
var config = await AppConfig.Load();

if (config is null) {
await Console.Out.WriteLineAsync("No config file found.");
await Console.Out.WriteLineAsync($" Path: {AppConfig.GetConfigPath()}");
await Console.Out.WriteLineAsync(" Run `kapacitor setup` to create one.");

return 0;
}

var json = JsonSerializer.Serialize(config, ConfigJsonContextIndented.Default.KapacitorConfig);
var profileConfig = await AppConfig.LoadProfileConfig();
var json = JsonSerializer.Serialize(profileConfig, ProfileConfigJsonContextIndented.Default.ProfileConfig);
await Console.Out.WriteLineAsync(json);
await Console.Out.WriteLineAsync();
await Console.Out.WriteLineAsync($" Path: {AppConfig.GetConfigPath()}");
Expand All @@ -41,22 +32,26 @@ static async Task<int> Show() {
}

static async Task<int> Set(string key, string value) {
var config = await AppConfig.Load() ?? new KapacitorConfig();

config = key switch {
"server_url" => config with { ServerUrl = value },
"daemon.name" => config with { Daemon = (config.Daemon ?? new DaemonSettings()) with { Name = value } },
"daemon.max_agents" when int.TryParse(value, out var n) => config with { Daemon = (config.Daemon ?? new DaemonSettings()) with { MaxAgents = n } },
"update_check" when bool.TryParse(value, out var b) => config with { UpdateCheck = b },
"default_visibility" when value is "private" or "org_public" or "public" => config with { DefaultVisibility = value },
var profileConfig = await AppConfig.LoadProfileConfig();
var profileName = profileConfig.ActiveProfile;
var profile = profileConfig.Profiles.GetValueOrDefault(profileName) ?? new Profile();

profile = key switch {
"server_url" => profile with { ServerUrl = value },
"daemon.name" => profile with { Daemon = (profile.Daemon ?? new DaemonSettings()) with { Name = value } },
"daemon.max_agents" when int.TryParse(value, out var n) => profile with { Daemon = (profile.Daemon ?? new DaemonSettings()) with { MaxAgents = n } },
"update_check" when bool.TryParse(value, out var b) => profile with { UpdateCheck = b },
"default_visibility" when value is "private" or "org_public" or "public" => profile with { DefaultVisibility = value },
"default_visibility" => throw new ArgumentException("Invalid value. Must be: private, org_public, or public"),
"excluded_repos" => config with { ExcludedRepos = value.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) },
"excluded_repos" => profile with { ExcludedRepos = value.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) },
_ => throw new ArgumentException($"Unknown config key: {key}")
};

await AppConfig.Save(config);
await Console.Out.WriteLineAsync($"Set {key} = {value}");
var profiles = new Dictionary<string, Profile>(profileConfig.Profiles) { [profileName] = profile };
profileConfig = profileConfig with { Profiles = profiles };
await AppConfig.SaveProfileConfig(profileConfig);

await Console.Out.WriteLineAsync($"Set {key} = {value} (profile: {profileName})");
return 0;
}

Expand Down
177 changes: 177 additions & 0 deletions src/kapacitor/Commands/ProfileCommand.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
using System.Text.Json;
using kapacitor.Config;

namespace kapacitor.Commands;

public static class ProfileCommand {
public static async Task<int> HandleAsync(string[] args) {
if (args.Length < 2) {
await PrintUsage();
return 1;
}

var configPath = AppConfig.GetConfigPath();

return args[1] switch {
"add" => await HandleAdd(configPath, args),
"list" => await HandleList(configPath),
"remove" when args.Length >= 3 => await RemoveProfile(configPath, args[2]),
"show" => await HandleShow(configPath, args),
_ => await PrintUsage()
};
}

static async Task<int> HandleAdd(string configPath, string[] args) {
if (args.Length < 3) {
await Console.Error.WriteLineAsync("Usage: kapacitor profile add <name> --server-url <url> [--remote <pattern>]...");
return 1;
}

var name = args[2];
var serverUrl = GetArg(args, "--server-url");

if (serverUrl is null) {
await Console.Error.WriteLineAsync("--server-url is required");
return 1;
}

var remotes = new List<string>();
for (var i = 0; i < args.Length; i++) {
if (args[i] == "--remote" && i + 1 < args.Length)
remotes.Add(args[++i]);
}

return await AddProfile(configPath, name, serverUrl, remotes.ToArray());
}

internal static async Task<int> AddProfile(string configPath, string name, string serverUrl, string[] remotes) {
var config = await LoadConfig(configPath);

if (config.Profiles.ContainsKey(name)) {
await Console.Error.WriteLineAsync($"Profile '{name}' already exists. Remove it first.");
return 1;
}

var profiles = new Dictionary<string, Profile>(config.Profiles) {
[name] = new() {
ServerUrl = AppConfig.NormalizeUrl(serverUrl),
Remotes = remotes
}
};

config = config with { Profiles = profiles };
await SaveConfig(configPath, config);

await Console.Out.WriteLineAsync($"Profile '{name}' added.");
return 0;
}

static async Task<int> HandleList(string configPath) {
var config = await LoadConfig(configPath);

foreach (var (name, profile) in config.Profiles) {
var active = name == config.ActiveProfile ? " (active)" : "";
var url = profile.ServerUrl ?? "(no server URL)";
await Console.Out.WriteLineAsync($" {name}{active} — {url}");

if (profile.Remotes is { Length: > 0 }) {
foreach (var remote in profile.Remotes)
await Console.Out.WriteLineAsync($" remote: {remote}");
}
}

return 0;
}

internal static async Task<int> RemoveProfile(string configPath, string name) {
if (name == "default") {
await Console.Error.WriteLineAsync("Cannot remove the default profile.");
return 1;
}

var config = await LoadConfig(configPath);

if (!config.Profiles.ContainsKey(name)) {
await Console.Error.WriteLineAsync($"Profile '{name}' not found.");
return 1;
}

var profiles = new Dictionary<string, Profile>(config.Profiles);
profiles.Remove(name);

var bindings = new Dictionary<string, string>(config.ProfileBindings);
var staleKeys = bindings.Where(kv => kv.Value == name).Select(kv => kv.Key).ToList();
foreach (var key in staleKeys) bindings.Remove(key);

config = config with {
Profiles = profiles,
ProfileBindings = bindings,
ActiveProfile = config.ActiveProfile == name ? "default" : config.ActiveProfile
};

await SaveConfig(configPath, config);
await Console.Out.WriteLineAsync($"Profile '{name}' removed.");
return 0;
}

static async Task<int> HandleShow(string configPath, string[] args) {
var config = await LoadConfig(configPath);
var name = args.Length >= 3 ? args[2] : config.ActiveProfile;

if (!config.Profiles.TryGetValue(name, out var profile)) {
await Console.Error.WriteLineAsync($"Profile '{name}' not found.");
return 1;
}

await Console.Out.WriteLineAsync($"Profile: {name}");
await Console.Out.WriteLineAsync($" server_url: {profile.ServerUrl ?? "(not set)"}");
await Console.Out.WriteLineAsync($" default_visibility: {profile.DefaultVisibility}");
await Console.Out.WriteLineAsync($" update_check: {profile.UpdateCheck}");
await Console.Out.WriteLineAsync($" daemon.name: {profile.Daemon?.Name ?? "(not set)"}");
await Console.Out.WriteLineAsync($" daemon.max_agents: {profile.Daemon?.MaxAgents ?? 5}");

if (profile.Remotes is { Length: > 0 }) {
await Console.Out.WriteLineAsync($" remotes:");
foreach (var r in profile.Remotes)
await Console.Out.WriteLineAsync($" - {r}");
}

if (profile.ExcludedRepos is { Length: > 0 }) {
await Console.Out.WriteLineAsync($" excluded_repos: {string.Join(", ", profile.ExcludedRepos)}");
}

return 0;
}

static async Task<ProfileConfig> LoadConfig(string configPath) {
if (!File.Exists(configPath))
return new ProfileConfig { Profiles = new Dictionary<string, Profile> { ["default"] = new() } };

var json = await File.ReadAllTextAsync(configPath);
return ConfigMigration.MigrateIfNeeded(json).Config;
}

static async Task SaveConfig(string configPath, ProfileConfig config) {
var dir = Path.GetDirectoryName(configPath)!;
Directory.CreateDirectory(dir);
var tempPath = $"{configPath}.tmp";
await File.WriteAllBytesAsync(tempPath,
JsonSerializer.SerializeToUtf8Bytes(config, ProfileConfigJsonContextIndented.Default.ProfileConfig));
File.Move(tempPath, configPath, overwrite: true);
}

static async Task<int> PrintUsage() {
await Console.Error.WriteLineAsync("Usage: kapacitor profile <add|list|remove|show>");
await Console.Error.WriteLineAsync();
await Console.Error.WriteLineAsync(" add <name> --server-url <url> [--remote <pattern>]...");
await Console.Error.WriteLineAsync(" list Show all profiles");
await Console.Error.WriteLineAsync(" remove <name> Remove a profile");
await Console.Error.WriteLineAsync(" show [name] Show profile details");
return 1;
}

static string? GetArg(string[] args, string name) {
var idx = Array.IndexOf(args, name);
return idx >= 0 && idx + 1 < args.Length ? args[idx + 1] : null;
}
}
19 changes: 13 additions & 6 deletions src/kapacitor/Commands/SetupCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,9 @@ public static async Task<int> HandleAsync(string[] args) {
await Console.Out.WriteLineAsync();

// Check if already configured
var existing = await AppConfig.Load();
var existingTokens = await TokenStore.LoadAsync();
var existingProfile = await AppConfig.LoadProfileConfig();
var existing = existingProfile.Profiles.GetValueOrDefault("default");
var existingTokens = await TokenStore.LoadAsync();

if (existing?.ServerUrl is not null && existingTokens is not null && !noPrompt) {
Console.Write($"Already configured for {existing.ServerUrl} as {existingTokens.GitHubUsername}. Re-run setup? [y/N] ");
Expand Down Expand Up @@ -216,14 +217,20 @@ public static async Task<int> HandleAsync(string[] args) {
await Console.Out.WriteLineAsync();

// Save config
var config = existing ?? new KapacitorConfig();
var profileConfig = await AppConfig.LoadProfileConfig();
var defaultProfile = profileConfig.Profiles.GetValueOrDefault("default") ?? new Profile();

config = config with {
defaultProfile = defaultProfile with {
ServerUrl = serverUrl,
DefaultVisibility = defaultVisibility,
Daemon = (config.Daemon ?? new DaemonSettings()) with { Name = daemonName }
Daemon = (defaultProfile.Daemon ?? new DaemonSettings()) with { Name = daemonName }
};
await AppConfig.Save(config);

var profiles = new Dictionary<string, Profile>(profileConfig.Profiles) {
["default"] = defaultProfile
};
profileConfig = profileConfig with { Profiles = profiles };
await AppConfig.SaveProfileConfig(profileConfig);

var finalTokens = await TokenStore.LoadAsync();
await Console.Out.WriteLineAsync("Setup complete!");
Expand Down
Loading
Loading