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
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,15 +31,15 @@ The CLI is compiled with NativeAOT — fast startup, no runtime dependency.
kapacitor setup
```

The setup wizard walks you through everything:
The setup wizard walks you through:

1. **Server URL** — enter the URL your admin provided
2. **Login** — authenticates via GitHub Device Flow (if the server requires auth)
3. **Default visibility** — choose how your sessions are visible to others (all private, org repos public, or all public)
4. **Claude Code hooks** — choose where to install hooks (user-wide, project-only, or full plugin)
3. **Default visibility** — choose how your sessions are visible to others
4. **Claude Code plugin** — installs hooks, skills, and collaborative memory (user-wide or project-only)
5. **Agent daemon** — configure the daemon name for remote agent execution

That's it. Verify with `kapacitor whoami` and `kapacitor status`.
Verify with `kapacitor whoami` and `kapacitor status`.

For non-interactive environments:

Expand Down
5 changes: 3 additions & 2 deletions src/kapacitor/ClaudePaths.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@ namespace kapacitor;
static class ClaudePaths {
static readonly string Home = Path.Combine(PathHelpers.HomeDirectory, ".claude");

public static string Projects { get; } = Path.Combine(Home, "projects");
public static string Plans { get; } = Path.Combine(Home, "plans");
public static string Projects { get; } = Path.Combine(Home, "projects");
public static string Plans { get; } = Path.Combine(Home, "plans");
public static string UserSettings { get; } = Path.Combine(Home, "settings.json");

/// <summary>
/// Returns the project directory for a given repo path.
Expand Down
103 changes: 100 additions & 3 deletions src/kapacitor/Commands/SetupCommand.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using kapacitor.Auth;
using kapacitor.Config;
// ReSharper disable MethodHasAsyncOverload
Expand Down Expand Up @@ -138,12 +140,58 @@ public static async Task<int> HandleAsync(string[] args) {
var pluginPath = ResolvePluginPath();

if (pluginPath is not null) {
await Console.Out.WriteLineAsync(" Install (or update) the plugin by running this inside Claude Code:");
var marketplacePath = Path.GetDirectoryName(pluginPath)!;

await Console.Out.WriteLineAsync(" Where should the plugin be installed?");
await Console.Out.WriteLineAsync();
await Console.Out.WriteLineAsync($" /plugin install {pluginPath}");
await Console.Out.WriteLineAsync(" 1) User-wide — all Claude Code sessions (recommended)");
await Console.Out.WriteLineAsync(" 2) This project only");
await Console.Out.WriteLineAsync(" 3) Skip — I'll install it manually");
await Console.Out.WriteLineAsync();

string pluginScope;

if (noPrompt) {
pluginScope = GetArg(args, "--plugin-scope") ?? "user";
await Console.Out.WriteLineAsync($" Plugin scope: {pluginScope}");
} else {
while (true) {
Console.Write(" Choose [1-3] (default: 1): ");
var choice = Console.ReadLine()?.Trim();

pluginScope = choice switch {
"" or null or "1" => "user",
"2" => "project",
"3" => "skip",
_ => ""
};

if (pluginScope != "") break;

await Console.Out.WriteLineAsync(" Invalid choice. Please enter 1, 2, or 3.");
}
}

if (pluginScope == "skip") {
await Console.Out.WriteLineAsync(" Skipped. Install manually inside Claude Code:");
await Console.Out.WriteLineAsync($" /plugin install {pluginPath}");
} else {
var settingsPath = pluginScope == "project"
? Path.Combine(Environment.CurrentDirectory, ".claude", "settings.local.json")
: ClaudePaths.UserSettings;

var installed = InstallPlugin(settingsPath, marketplacePath);

if (installed) {
var scope = pluginScope == "project" ? "project" : "user";
await Console.Out.WriteLineAsync($" ✓ Plugin registered ({scope}: {settingsPath})");
} else {
await Console.Out.WriteLineAsync(" ⚠ Could not update settings. Install manually inside Claude Code:");
await Console.Out.WriteLineAsync($" /plugin install {pluginPath}");
}
}
} else {
await Console.Out.WriteLineAsync(" Plugin not found. Install kapacitor via npm first:");
await Console.Out.WriteLineAsync(" ⚠ Plugin directory not found. Re-install kapacitor via npm:");
await Console.Out.WriteLineAsync(" npm install -g @kurrent/kapacitor");
}

Expand Down Expand Up @@ -225,4 +273,53 @@ public static async Task<int> HandleAsync(string[] args) {

return idx >= 0 && idx + 1 < args.Length ? args[idx + 1] : null;
}

static readonly JsonSerializerOptions WriteOpts = new() { WriteIndented = true };

/// <summary>
/// Registers the kapacitor plugin in a Claude Code settings.json file by merging
/// the marketplace source and enabling the plugin. Preserves all existing settings.
/// </summary>
internal static bool InstallPlugin(string settingsPath, string marketplacePath) {
try {
JsonObject root = [];

if (File.Exists(settingsPath)) {
try {
if (JsonNode.Parse(File.ReadAllText(settingsPath)) is JsonObject obj)
root = obj;
} catch {
// Malformed JSON — start fresh
}
}

// Ensure extraKnownMarketplaces.kurrent exists with the correct path
if (root["extraKnownMarketplaces"] is not JsonObject marketplaces) {
marketplaces = [];
root["extraKnownMarketplaces"] = marketplaces;
}

marketplaces["kurrent"] = new JsonObject {
["source"] = new JsonObject {
["source"] = "directory",
["path"] = marketplacePath
}
};

// Ensure enabledPlugins.kapacitor@kurrent is true
if (root["enabledPlugins"] is not JsonObject enabled) {
enabled = [];
root["enabledPlugins"] = enabled;
}

enabled["kapacitor@kurrent"] = true;

Directory.CreateDirectory(Path.GetDirectoryName(settingsPath)!);
File.WriteAllText(settingsPath, root.ToJsonString(WriteOpts));

return true;
} catch {
return false;
}
}
}
129 changes: 129 additions & 0 deletions test/kapacitor.Tests.Unit/SetupCommandTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
using System.Text.Json.Nodes;
using kapacitor.Commands;

namespace kapacitor.Tests.Unit;

public class SetupCommandTests {
[Test]
public async Task InstallPlugin_CreatesNewSettingsFile() {
using var tmp = new TempDir();
var settingsPath = Path.Combine(tmp.Path, "settings.json");
var marketplace = "/opt/kapacitor";

var result = SetupCommand.InstallPlugin(settingsPath, marketplace);

await Assert.That(result).IsTrue();

var root = JsonNode.Parse(File.ReadAllText(settingsPath))!.AsObject();

await Assert.That(root["extraKnownMarketplaces"]?["kurrent"]?["source"]?["path"]?.GetValue<string>())
.IsEqualTo(marketplace);

await Assert.That(root["enabledPlugins"]?["kapacitor@kurrent"]?.GetValue<bool>())

Check warning on line 22 in test/kapacitor.Tests.Unit/SetupCommandTests.cs

View workflow job for this annotation

GitHub Actions / Build and test

Use .IsTrue() instead of .IsEqualTo(true)
.IsEqualTo(true);
}

[Test]
public async Task InstallPlugin_PreservesExistingSettings() {
using var tmp = new TempDir();
var settingsPath = Path.Combine(tmp.Path, "settings.json");
var marketplace = "/opt/kapacitor";

// Pre-populate with existing settings
File.WriteAllText(settingsPath, """
{
"permissions": { "allow": ["Bash"] },
"enabledPlugins": { "other-plugin@foo": true }
}
""");

var result = SetupCommand.InstallPlugin(settingsPath, marketplace);

await Assert.That(result).IsTrue();

var root = JsonNode.Parse(File.ReadAllText(settingsPath))!.AsObject();

// Original settings preserved
await Assert.That(root["permissions"]?["allow"]?[0]?.GetValue<string>())
.IsEqualTo("Bash");

await Assert.That(root["enabledPlugins"]?["other-plugin@foo"]?.GetValue<bool>())

Check warning on line 50 in test/kapacitor.Tests.Unit/SetupCommandTests.cs

View workflow job for this annotation

GitHub Actions / Build and test

Use .IsTrue() instead of .IsEqualTo(true)
.IsEqualTo(true);

// Plugin added
await Assert.That(root["enabledPlugins"]?["kapacitor@kurrent"]?.GetValue<bool>())

Check warning on line 54 in test/kapacitor.Tests.Unit/SetupCommandTests.cs

View workflow job for this annotation

GitHub Actions / Build and test

Use .IsTrue() instead of .IsEqualTo(true)
.IsEqualTo(true);

await Assert.That(root["extraKnownMarketplaces"]?["kurrent"]?["source"]?["path"]?.GetValue<string>())
.IsEqualTo(marketplace);
}

[Test]
public async Task InstallPlugin_UpdatesExistingMarketplacePath() {
using var tmp = new TempDir();
var settingsPath = Path.Combine(tmp.Path, "settings.json");
var newPath = "/new/path";

// Pre-populate with old marketplace path
File.WriteAllText(settingsPath, """
{
"extraKnownMarketplaces": {
"kurrent": { "source": { "source": "directory", "path": "/old/path" } }
},
"enabledPlugins": { "kapacitor@kurrent": true }
}
""");

var result = SetupCommand.InstallPlugin(settingsPath, newPath);

await Assert.That(result).IsTrue();

var root = JsonNode.Parse(File.ReadAllText(settingsPath))!.AsObject();

await Assert.That(root["extraKnownMarketplaces"]?["kurrent"]?["source"]?["path"]?.GetValue<string>())
.IsEqualTo(newPath);
}

[Test]
public async Task InstallPlugin_CreatesIntermediateDirectories() {
using var tmp = new TempDir();
var settingsPath = Path.Combine(tmp.Path, ".claude", "nested", "settings.json");
var marketplace = "/opt/kapacitor";

var result = SetupCommand.InstallPlugin(settingsPath, marketplace);

await Assert.That(result).IsTrue();
await Assert.That(File.Exists(settingsPath)).IsTrue();
}

[Test]
public async Task InstallPlugin_MalformedJson_StartsFromScratch() {
using var tmp = new TempDir();
var settingsPath = Path.Combine(tmp.Path, "settings.json");
var marketplace = "/opt/kapacitor";

File.WriteAllText(settingsPath, "not json {{{");

var result = SetupCommand.InstallPlugin(settingsPath, marketplace);

await Assert.That(result).IsTrue();

var root = JsonNode.Parse(File.ReadAllText(settingsPath))!.AsObject();

await Assert.That(root["enabledPlugins"]?["kapacitor@kurrent"]?.GetValue<bool>())

Check warning on line 113 in test/kapacitor.Tests.Unit/SetupCommandTests.cs

View workflow job for this annotation

GitHub Actions / Build and test

Use .IsTrue() instead of .IsEqualTo(true)
.IsEqualTo(true);
}

sealed class TempDir : IDisposable {
public string Path { get; } = System.IO.Path.Combine(
System.IO.Path.GetTempPath(),
"kapacitor-test-" + Guid.NewGuid().ToString("N")[..8]
);

public TempDir() => Directory.CreateDirectory(Path);

public void Dispose() {
try { Directory.Delete(Path, true); } catch { /* best effort */ }
}
}
}
Loading