Skip to content

Commit 6ea696c

Browse files
Merge pull request #1 from kurrent-io/feat/auto-plugin-install
Auto-install Claude Code plugin during setup
2 parents 88c13d1 + 644570a commit 6ea696c

File tree

4 files changed

+236
-9
lines changed

4 files changed

+236
-9
lines changed

README.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,15 +31,15 @@ The CLI is compiled with NativeAOT — fast startup, no runtime dependency.
3131
kapacitor setup
3232
```
3333

34-
The setup wizard walks you through everything:
34+
The setup wizard walks you through:
3535

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

42-
That's it. Verify with `kapacitor whoami` and `kapacitor status`.
42+
Verify with `kapacitor whoami` and `kapacitor status`.
4343

4444
For non-interactive environments:
4545

src/kapacitor/ClaudePaths.cs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,9 @@ namespace kapacitor;
33
static class ClaudePaths {
44
static readonly string Home = Path.Combine(PathHelpers.HomeDirectory, ".claude");
55

6-
public static string Projects { get; } = Path.Combine(Home, "projects");
7-
public static string Plans { get; } = Path.Combine(Home, "plans");
6+
public static string Projects { get; } = Path.Combine(Home, "projects");
7+
public static string Plans { get; } = Path.Combine(Home, "plans");
8+
public static string UserSettings { get; } = Path.Combine(Home, "settings.json");
89

910
/// <summary>
1011
/// Returns the project directory for a given repo path.

src/kapacitor/Commands/SetupCommand.cs

Lines changed: 100 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
using System.Text.Json;
2+
using System.Text.Json.Nodes;
13
using kapacitor.Auth;
24
using kapacitor.Config;
35
// ReSharper disable MethodHasAsyncOverload
@@ -138,12 +140,58 @@ public static async Task<int> HandleAsync(string[] args) {
138140
var pluginPath = ResolvePluginPath();
139141

140142
if (pluginPath is not null) {
141-
await Console.Out.WriteLineAsync(" Install (or update) the plugin by running this inside Claude Code:");
143+
var marketplacePath = Path.GetDirectoryName(pluginPath)!;
144+
145+
await Console.Out.WriteLineAsync(" Where should the plugin be installed?");
142146
await Console.Out.WriteLineAsync();
143-
await Console.Out.WriteLineAsync($" /plugin install {pluginPath}");
147+
await Console.Out.WriteLineAsync(" 1) User-wide — all Claude Code sessions (recommended)");
148+
await Console.Out.WriteLineAsync(" 2) This project only");
149+
await Console.Out.WriteLineAsync(" 3) Skip — I'll install it manually");
144150
await Console.Out.WriteLineAsync();
151+
152+
string pluginScope;
153+
154+
if (noPrompt) {
155+
pluginScope = GetArg(args, "--plugin-scope") ?? "user";
156+
await Console.Out.WriteLineAsync($" Plugin scope: {pluginScope}");
157+
} else {
158+
while (true) {
159+
Console.Write(" Choose [1-3] (default: 1): ");
160+
var choice = Console.ReadLine()?.Trim();
161+
162+
pluginScope = choice switch {
163+
"" or null or "1" => "user",
164+
"2" => "project",
165+
"3" => "skip",
166+
_ => ""
167+
};
168+
169+
if (pluginScope != "") break;
170+
171+
await Console.Out.WriteLineAsync(" Invalid choice. Please enter 1, 2, or 3.");
172+
}
173+
}
174+
175+
if (pluginScope == "skip") {
176+
await Console.Out.WriteLineAsync(" Skipped. Install manually inside Claude Code:");
177+
await Console.Out.WriteLineAsync($" /plugin install {pluginPath}");
178+
} else {
179+
var settingsPath = pluginScope == "project"
180+
? Path.Combine(Environment.CurrentDirectory, ".claude", "settings.local.json")
181+
: ClaudePaths.UserSettings;
182+
183+
var installed = InstallPlugin(settingsPath, marketplacePath);
184+
185+
if (installed) {
186+
var scope = pluginScope == "project" ? "project" : "user";
187+
await Console.Out.WriteLineAsync($" ✓ Plugin registered ({scope}: {settingsPath})");
188+
} else {
189+
await Console.Out.WriteLineAsync(" ⚠ Could not update settings. Install manually inside Claude Code:");
190+
await Console.Out.WriteLineAsync($" /plugin install {pluginPath}");
191+
}
192+
}
145193
} else {
146-
await Console.Out.WriteLineAsync(" Plugin not found. Install kapacitor via npm first:");
194+
await Console.Out.WriteLineAsync(" Plugin directory not found. Re-install kapacitor via npm:");
147195
await Console.Out.WriteLineAsync(" npm install -g @kurrent/kapacitor");
148196
}
149197

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

226274
return idx >= 0 && idx + 1 < args.Length ? args[idx + 1] : null;
227275
}
276+
277+
static readonly JsonSerializerOptions WriteOpts = new() { WriteIndented = true };
278+
279+
/// <summary>
280+
/// Registers the kapacitor plugin in a Claude Code settings.json file by merging
281+
/// the marketplace source and enabling the plugin. Preserves all existing settings.
282+
/// </summary>
283+
internal static bool InstallPlugin(string settingsPath, string marketplacePath) {
284+
try {
285+
JsonObject root = [];
286+
287+
if (File.Exists(settingsPath)) {
288+
try {
289+
if (JsonNode.Parse(File.ReadAllText(settingsPath)) is JsonObject obj)
290+
root = obj;
291+
} catch {
292+
// Malformed JSON — start fresh
293+
}
294+
}
295+
296+
// Ensure extraKnownMarketplaces.kurrent exists with the correct path
297+
if (root["extraKnownMarketplaces"] is not JsonObject marketplaces) {
298+
marketplaces = [];
299+
root["extraKnownMarketplaces"] = marketplaces;
300+
}
301+
302+
marketplaces["kurrent"] = new JsonObject {
303+
["source"] = new JsonObject {
304+
["source"] = "directory",
305+
["path"] = marketplacePath
306+
}
307+
};
308+
309+
// Ensure enabledPlugins.kapacitor@kurrent is true
310+
if (root["enabledPlugins"] is not JsonObject enabled) {
311+
enabled = [];
312+
root["enabledPlugins"] = enabled;
313+
}
314+
315+
enabled["kapacitor@kurrent"] = true;
316+
317+
Directory.CreateDirectory(Path.GetDirectoryName(settingsPath)!);
318+
File.WriteAllText(settingsPath, root.ToJsonString(WriteOpts));
319+
320+
return true;
321+
} catch {
322+
return false;
323+
}
324+
}
228325
}
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
using System.Text.Json.Nodes;
2+
using kapacitor.Commands;
3+
4+
namespace kapacitor.Tests.Unit;
5+
6+
public class SetupCommandTests {
7+
[Test]
8+
public async Task InstallPlugin_CreatesNewSettingsFile() {
9+
using var tmp = new TempDir();
10+
var settingsPath = Path.Combine(tmp.Path, "settings.json");
11+
var marketplace = "/opt/kapacitor";
12+
13+
var result = SetupCommand.InstallPlugin(settingsPath, marketplace);
14+
15+
await Assert.That(result).IsTrue();
16+
17+
var root = JsonNode.Parse(File.ReadAllText(settingsPath))!.AsObject();
18+
19+
await Assert.That(root["extraKnownMarketplaces"]?["kurrent"]?["source"]?["path"]?.GetValue<string>())
20+
.IsEqualTo(marketplace);
21+
22+
await Assert.That(root["enabledPlugins"]?["kapacitor@kurrent"]?.GetValue<bool>())
23+
.IsEqualTo(true);
24+
}
25+
26+
[Test]
27+
public async Task InstallPlugin_PreservesExistingSettings() {
28+
using var tmp = new TempDir();
29+
var settingsPath = Path.Combine(tmp.Path, "settings.json");
30+
var marketplace = "/opt/kapacitor";
31+
32+
// Pre-populate with existing settings
33+
File.WriteAllText(settingsPath, """
34+
{
35+
"permissions": { "allow": ["Bash"] },
36+
"enabledPlugins": { "other-plugin@foo": true }
37+
}
38+
""");
39+
40+
var result = SetupCommand.InstallPlugin(settingsPath, marketplace);
41+
42+
await Assert.That(result).IsTrue();
43+
44+
var root = JsonNode.Parse(File.ReadAllText(settingsPath))!.AsObject();
45+
46+
// Original settings preserved
47+
await Assert.That(root["permissions"]?["allow"]?[0]?.GetValue<string>())
48+
.IsEqualTo("Bash");
49+
50+
await Assert.That(root["enabledPlugins"]?["other-plugin@foo"]?.GetValue<bool>())
51+
.IsEqualTo(true);
52+
53+
// Plugin added
54+
await Assert.That(root["enabledPlugins"]?["kapacitor@kurrent"]?.GetValue<bool>())
55+
.IsEqualTo(true);
56+
57+
await Assert.That(root["extraKnownMarketplaces"]?["kurrent"]?["source"]?["path"]?.GetValue<string>())
58+
.IsEqualTo(marketplace);
59+
}
60+
61+
[Test]
62+
public async Task InstallPlugin_UpdatesExistingMarketplacePath() {
63+
using var tmp = new TempDir();
64+
var settingsPath = Path.Combine(tmp.Path, "settings.json");
65+
var newPath = "/new/path";
66+
67+
// Pre-populate with old marketplace path
68+
File.WriteAllText(settingsPath, """
69+
{
70+
"extraKnownMarketplaces": {
71+
"kurrent": { "source": { "source": "directory", "path": "/old/path" } }
72+
},
73+
"enabledPlugins": { "kapacitor@kurrent": true }
74+
}
75+
""");
76+
77+
var result = SetupCommand.InstallPlugin(settingsPath, newPath);
78+
79+
await Assert.That(result).IsTrue();
80+
81+
var root = JsonNode.Parse(File.ReadAllText(settingsPath))!.AsObject();
82+
83+
await Assert.That(root["extraKnownMarketplaces"]?["kurrent"]?["source"]?["path"]?.GetValue<string>())
84+
.IsEqualTo(newPath);
85+
}
86+
87+
[Test]
88+
public async Task InstallPlugin_CreatesIntermediateDirectories() {
89+
using var tmp = new TempDir();
90+
var settingsPath = Path.Combine(tmp.Path, ".claude", "nested", "settings.json");
91+
var marketplace = "/opt/kapacitor";
92+
93+
var result = SetupCommand.InstallPlugin(settingsPath, marketplace);
94+
95+
await Assert.That(result).IsTrue();
96+
await Assert.That(File.Exists(settingsPath)).IsTrue();
97+
}
98+
99+
[Test]
100+
public async Task InstallPlugin_MalformedJson_StartsFromScratch() {
101+
using var tmp = new TempDir();
102+
var settingsPath = Path.Combine(tmp.Path, "settings.json");
103+
var marketplace = "/opt/kapacitor";
104+
105+
File.WriteAllText(settingsPath, "not json {{{");
106+
107+
var result = SetupCommand.InstallPlugin(settingsPath, marketplace);
108+
109+
await Assert.That(result).IsTrue();
110+
111+
var root = JsonNode.Parse(File.ReadAllText(settingsPath))!.AsObject();
112+
113+
await Assert.That(root["enabledPlugins"]?["kapacitor@kurrent"]?.GetValue<bool>())
114+
.IsEqualTo(true);
115+
}
116+
117+
sealed class TempDir : IDisposable {
118+
public string Path { get; } = System.IO.Path.Combine(
119+
System.IO.Path.GetTempPath(),
120+
"kapacitor-test-" + Guid.NewGuid().ToString("N")[..8]
121+
);
122+
123+
public TempDir() => Directory.CreateDirectory(Path);
124+
125+
public void Dispose() {
126+
try { Directory.Delete(Path, true); } catch { /* best effort */ }
127+
}
128+
}
129+
}

0 commit comments

Comments
 (0)