diff --git a/source/Calamari.AiAgent.Tests/Calamari.AiAgent.Tests.csproj b/source/Calamari.AiAgent.Tests/Calamari.AiAgent.Tests.csproj
new file mode 100644
index 000000000..169bd68cc
--- /dev/null
+++ b/source/Calamari.AiAgent.Tests/Calamari.AiAgent.Tests.csproj
@@ -0,0 +1,22 @@
+
+
+ Calamari.AiAgent.Tests
+ Calamari.AiAgent.Tests
+ net8.0
+ win-x64;linux-x64;osx-x64;linux-arm;linux-arm64
+ false
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/source/Calamari.AiAgent.Tests/ClaudeCodeBehaviour/ClaudeCodeProcessStartInfoFixture.cs b/source/Calamari.AiAgent.Tests/ClaudeCodeBehaviour/ClaudeCodeProcessStartInfoFixture.cs
new file mode 100644
index 000000000..0dceb0f68
--- /dev/null
+++ b/source/Calamari.AiAgent.Tests/ClaudeCodeBehaviour/ClaudeCodeProcessStartInfoFixture.cs
@@ -0,0 +1,19 @@
+using Calamari.AiAgent.ClaudeCodeBehaviour;
+using FluentAssertions;
+using NUnit.Framework;
+
+namespace Calamari.AiAgent.Tests.ClaudeCodeBehaviour;
+
+[TestFixture]
+public class ClaudeCodeProcessStartInfoFixture
+{
+ [TestCase("simple", "'simple'")]
+ [TestCase("has space", "'has space'")]
+ [TestCase("it's", @"'it'\''s'")]
+ [TestCase("", "''")]
+ [TestCase("a'b'c", @"'a'\''b'\''c'")]
+ public void ShellQuote_QuotesCorrectly(string input, string expected)
+ {
+ ClaudeCodeProcessStartInfo.ShellQuote(input).Should().Be(expected);
+ }
+}
diff --git a/source/Calamari.AiAgent.Tests/ClaudeCodeBehaviour/ClaudeCodeStreamProcessorFixture.cs b/source/Calamari.AiAgent.Tests/ClaudeCodeBehaviour/ClaudeCodeStreamProcessorFixture.cs
new file mode 100644
index 000000000..81f1ffcc7
--- /dev/null
+++ b/source/Calamari.AiAgent.Tests/ClaudeCodeBehaviour/ClaudeCodeStreamProcessorFixture.cs
@@ -0,0 +1,272 @@
+using System.Linq;
+using System.Text;
+using Calamari.AiAgent.ClaudeCodeBehaviour;
+using Calamari.Common.Plumbing.ServiceMessages;
+using Calamari.Testing.Helpers;
+using FluentAssertions;
+using NUnit.Framework;
+using Octopus.Calamari.Contracts.ClaudeCode;
+
+namespace Calamari.AiAgent.Tests.ClaudeCodeBehaviour;
+
+[TestFixture]
+public class ClaudeCodeStreamProcessorFixture
+{
+ InMemoryLog log = null!;
+ StringBuilder responseBuilder = null!;
+ ClaudeCodeStreamProcessor processor = null!;
+
+ [SetUp]
+ public void SetUp()
+ {
+ log = new InMemoryLog();
+ responseBuilder = new StringBuilder();
+ processor = new ClaudeCodeStreamProcessor(log, responseBuilder);
+ }
+
+ [Test]
+ public void TextContentBlock_AppendsToResponseAndLogsInfo()
+ {
+ var json = """
+ {"type":"assistant","message":{"content":[{"type":"text","text":"Hello world"}]}}
+ """;
+
+ processor.ProcessLine(json);
+
+ responseBuilder.ToString().Should().Be("Hello world");
+ log.Messages.Should().Contain(m => m.Level == InMemoryLog.Level.Info && m.FormattedMessage.Contains("Hello world"));
+ }
+
+ [Test]
+ public void ThinkingBlock_LogsVerbose()
+ {
+ var json = """
+ {"type":"assistant","message":{"content":[{"type":"thinking","thinking":"Let me reason about this"}]}}
+ """;
+
+ processor.ProcessLine(json);
+
+ responseBuilder.ToString().Should().BeEmpty();
+ log.Messages.Should().Contain(m => m.Level == InMemoryLog.Level.Verbose && m.FormattedMessage.Contains("Let me reason about this"));
+ }
+
+ [Test]
+ public void RedactedThinkingBlock_LogsRedactedMessage()
+ {
+ var json = """
+ {"type":"assistant","message":{"content":[{"type":"redacted_thinking"}]}}
+ """;
+
+ processor.ProcessLine(json);
+
+ responseBuilder.ToString().Should().BeEmpty();
+ log.Messages.Should().Contain(m => m.Level == InMemoryLog.Level.Verbose && m.FormattedMessage.Contains(""));
+ }
+
+ [Test]
+ public void ToolUseBlock_LogsToolName()
+ {
+ var json = """
+ {"type":"assistant","message":{"content":[{"type":"tool_use","name":"Read","id":"toolu_123","input":{"file_path":"/tmp/test.txt"}}]}}
+ """;
+
+ processor.ProcessLine(json);
+
+ responseBuilder.ToString().Should().BeEmpty();
+ log.Messages.Should().Contain(m => m.Level == InMemoryLog.Level.Verbose && m.FormattedMessage.Contains("Read"));
+ }
+
+ [Test]
+ public void ToolResultError_LogsWithFailedMessage()
+ {
+ var json = """
+ {"type":"user","message":{"content":[{"type":"tool_result","tool_use_id":"toolu_123","is_error":true,"content":"File not found"}]}}
+ """;
+
+ processor.ProcessLine(json);
+
+ log.Messages.Should().Contain(m => m.FormattedMessage.Contains("toolu_123") && m.FormattedMessage.Contains("failed"));
+ }
+
+ [Test]
+ public void ToolResultSuccess_LogsVerbose()
+ {
+ var json = """
+ {"type":"user","message":{"content":[{"type":"tool_result","tool_use_id":"toolu_123","name":"Read","is_error":false}]}}
+ """;
+
+ processor.ProcessLine(json);
+
+ log.Messages.Should().Contain(m => m.Level == InMemoryLog.Level.Verbose && m.FormattedMessage.Contains("Read") && m.FormattedMessage.Contains("completed"));
+ }
+
+ [Test]
+ public void ResultEvent_EmitsUsageServiceMessage()
+ {
+ var json = """
+ {"type":"result","result":"Paris","cost_usd":0.003,"total_cost_usd":0.003,"duration_ms":4521,"duration_api_ms":3200,"num_turns":1,"usage":{"input_tokens":100,"output_tokens":50,"cache_read_input_tokens":10,"cache_creation_input_tokens":5}}
+ """;
+
+ processor.ProcessLine(json);
+
+ log.ServiceMessages.Should().Contain(m => m.Name == ClaudeCodeServiceMessages.Usage.Name);
+
+ var msg = log.ServiceMessages.First(m => m.Name == ClaudeCodeServiceMessages.Usage.Name);
+ msg.GetValue(ClaudeCodeServiceMessages.Usage.CostUsdAttribute).Should().NotBeNull();
+ msg.GetValue(ClaudeCodeServiceMessages.Usage.TotalCostUsdAttribute).Should().NotBeNull();
+ msg.GetValue(ClaudeCodeServiceMessages.Usage.DurationMsAttribute).Should().NotBeNull();
+ msg.GetValue(ClaudeCodeServiceMessages.Usage.NumTurnsAttribute).Should().NotBeNull();
+ msg.GetValue(ClaudeCodeServiceMessages.Usage.InputTokensAttribute).Should().NotBeNull();
+ msg.GetValue(ClaudeCodeServiceMessages.Usage.OutputTokensAttribute).Should().NotBeNull();
+ msg.GetValue(ClaudeCodeServiceMessages.Usage.CacheReadInputTokensAttribute).Should().NotBeNull();
+ msg.GetValue(ClaudeCodeServiceMessages.Usage.CacheCreationInputTokensAttribute).Should().NotBeNull();
+ }
+
+ [Test]
+ public void ResultEvent_FallsBackToResultText_WhenNoAssistantText()
+ {
+ var json = """
+ {"type":"result","result":"Paris","cost_usd":0.001}
+ """;
+
+ processor.ProcessLine(json);
+
+ responseBuilder.ToString().Should().Be("Paris");
+ }
+
+ [Test]
+ public void ResultEvent_DoesNotOverwriteAssistantText()
+ {
+ var assistantJson = """
+ {"type":"assistant","message":{"content":[{"type":"text","text":"The capital is Paris"}]}}
+ """;
+ var resultJson = """
+ {"type":"result","result":"The capital is Paris","cost_usd":0.001}
+ """;
+
+ processor.ProcessLine(assistantJson);
+ processor.ProcessLine(resultJson);
+
+ responseBuilder.ToString().Should().Be("The capital is Paris");
+ }
+
+ [Test]
+ public void UnknownEventType_LogsVerboseAndDoesNotThrow()
+ {
+ var json = """{"type":"stream_event","data":"something"}""";
+
+ var act = () => processor.ProcessLine(json);
+
+ act.Should().NotThrow();
+ log.Messages.Should().Contain(m => m.Level == InMemoryLog.Level.Verbose && m.FormattedMessage.Contains("unhandled event type"));
+ }
+
+ [Test]
+ public void UnknownContentBlockType_LogsVerboseAndContinues()
+ {
+ var json = """
+ {"type":"assistant","message":{"content":[{"type":"citations","data":"ref1"},{"type":"text","text":"Answer"}]}}
+ """;
+
+ var act = () => processor.ProcessLine(json);
+
+ act.Should().NotThrow();
+ responseBuilder.ToString().Should().Be("Answer");
+ log.Messages.Should().Contain(m => m.Level == InMemoryLog.Level.Verbose && m.FormattedMessage.Contains("unhandled block type"));
+ }
+
+ [Test]
+ public void UnknownSystemSubtype_DoesNotThrow()
+ {
+ var json = """
+ {"type":"system","subtype":"some_new_subtype","data":"whatever"}
+ """;
+
+ var act = () => processor.ProcessLine(json);
+
+ act.Should().NotThrow();
+ }
+
+ [Test]
+ public void ApiRetry_LogsWarning()
+ {
+ var json = """
+ {"type":"system","subtype":"api_retry","attempt":2,"retry_delay_ms":5000,"error":"rate_limit","error_status":429}
+ """;
+
+ processor.ProcessLine(json);
+
+ log.Messages.Should().Contain(m => m.Level == InMemoryLog.Level.Warn && m.FormattedMessage.Contains("rate_limit") && m.FormattedMessage.Contains("5000"));
+ }
+
+ [Test]
+ public void MalformedJson_DoesNotThrow()
+ {
+ var act = () => processor.ProcessLine("this is not json {{{");
+
+ act.Should().NotThrow();
+ }
+
+ [Test]
+ public void MultipleTextBlocks_ConcatenatesResponse()
+ {
+ var json1 = """
+ {"type":"assistant","message":{"content":[{"type":"text","text":"Hello "}]}}
+ """;
+ var json2 = """
+ {"type":"assistant","message":{"content":[{"type":"text","text":"world"}]}}
+ """;
+
+ processor.ProcessLine(json1);
+ processor.ProcessLine(json2);
+
+ responseBuilder.ToString().Should().Be("Hello world");
+ }
+
+ [Test]
+ public void ServerToolUse_LogsVerbose()
+ {
+ var json = """
+ {"type":"assistant","message":{"content":[{"type":"server_tool_use","name":"web_search"}]}}
+ """;
+
+ processor.ProcessLine(json);
+
+ log.Messages.Should().Contain(m => m.Level == InMemoryLog.Level.Verbose && m.FormattedMessage.Contains("web_search"));
+ }
+
+ [Test]
+ public void NullMessageContent_DoesNotThrow()
+ {
+ var json = """{"type":"assistant","message":{"content":null}}""";
+
+ var act = () => processor.ProcessLine(json);
+
+ act.Should().NotThrow();
+ }
+
+ [Test]
+ public void UserTextContent_DoesNotAppendToResponse()
+ {
+ var json = """
+ {"type":"user","message":{"content":[{"type":"text","text":"user input"}]}}
+ """;
+
+ processor.ProcessLine(json);
+
+ responseBuilder.ToString().Should().BeEmpty();
+ log.Messages.Should().Contain(m => m.Level == InMemoryLog.Level.Verbose && m.FormattedMessage.Contains("user input"));
+ }
+
+ [Test]
+ public void SyntheticUserMessage_IsSkipped()
+ {
+ var json = """
+ {"type":"user","message":{"content":[{"type":"text","text":"synthetic"}]},"isSynthetic":true}
+ """;
+
+ processor.ProcessLine(json);
+
+ log.Messages.Should().NotContain(m => m.FormattedMessage.Contains("synthetic"));
+ }
+}
\ No newline at end of file
diff --git a/source/Calamari.AiAgent.Tests/ClaudeCodeBehaviour/ClaudeCommandArgsBuilderFixture.cs b/source/Calamari.AiAgent.Tests/ClaudeCodeBehaviour/ClaudeCommandArgsBuilderFixture.cs
new file mode 100644
index 000000000..abb4f41b0
--- /dev/null
+++ b/source/Calamari.AiAgent.Tests/ClaudeCodeBehaviour/ClaudeCommandArgsBuilderFixture.cs
@@ -0,0 +1,151 @@
+using Calamari.AiAgent.ClaudeCodeBehaviour;
+using FluentAssertions;
+using NUnit.Framework;
+
+namespace Calamari.AiAgent.Tests.ClaudeCodeBehaviour;
+
+[TestFixture]
+public class ClaudeCommandArgsBuilderFixture
+{
+ ClaudeCommandArgsBuilder MinimalBuilder() =>
+ new ClaudeCommandArgsBuilder()
+ .WithPrompt("test prompt")
+ .WithModel("claude-sonnet-4-20250514");
+
+ [Test]
+ public void Build_IncludesRequiredFlags()
+ {
+ var args = MinimalBuilder().Build();
+
+ args.Should().Contain("-p");
+ args.Should().Contain("--model claude-sonnet-4-20250514");
+ args.Should().Contain("--output-format stream-json");
+ args.Should().Contain("--verbose");
+ args.Should().Contain("--permission-mode dontAsk");
+ args.Should().Contain("--no-session-persistence");
+ args.Should().Contain("--bare");
+ args.Should().Contain("--strict-mcp-config");
+ }
+
+ [Test]
+ public void Build_DefaultsMaxTurnsTo10_WhenNotSet()
+ {
+ var args = MinimalBuilder().Build();
+
+ args.Should().Contain("--max-turns 10");
+ }
+
+ [Test]
+ public void Build_UsesProvidedMaxTurns_WhenSet()
+ {
+ var args = MinimalBuilder().WithMaxTurns(5).Build();
+
+ args.Should().Contain("--max-turns 5");
+ args.Should().NotContain("--max-turns 10");
+ }
+
+ [Test]
+ public void Build_OmitsMaxBudgetUsd_WhenNotSet()
+ {
+ var args = MinimalBuilder().Build();
+
+ args.Should().NotContain("--max-budget-usd");
+ }
+
+ [Test]
+ public void Build_IncludesMaxBudgetUsd_WhenSet()
+ {
+ var args = MinimalBuilder().WithMaxBudgetUsd(1.50m).Build();
+
+ args.Should().Contain("--max-budget-usd 1.50");
+ }
+
+ [Test]
+ public void Build_IncludesAllowedTools_WhenSet()
+ {
+ var args = MinimalBuilder()
+ .WithAllowedTools(new[] { "Read", "Bash" })
+ .Build();
+
+ args.Should().Contain("--allowedTools Read,Bash");
+ }
+
+ [Test]
+ public void Build_OmitsAllowedTools_WhenEmpty()
+ {
+ var args = MinimalBuilder()
+ .WithAllowedTools(new string[0])
+ .Build();
+
+ args.Should().NotContain("--allowedTools");
+ }
+
+ [Test]
+ public void Build_IncludesSystemPromptFile_WhenSet()
+ {
+ var args = MinimalBuilder()
+ .WithSystemPromptFile("/tmp/system-prompt.md")
+ .Build();
+
+ args.Should().Contain("--system-prompt-file");
+ args.Should().Contain("/tmp/system-prompt.md");
+ }
+
+ [Test]
+ public void Build_OmitsSystemPromptFile_WhenNotSet()
+ {
+ var args = MinimalBuilder().Build();
+
+ args.Should().NotContain("--system-prompt-file");
+ }
+
+ [Test]
+ public void Build_IncludesEffort_WhenSet()
+ {
+ var args = MinimalBuilder().WithEffort("high").Build();
+
+ args.Should().Contain("--effort high");
+ }
+
+ [Test]
+ public void Build_OmitsEffort_WhenNotSet()
+ {
+ var args = MinimalBuilder().Build();
+
+ args.Should().NotContain("--effort");
+ }
+
+ [Test]
+ public void Build_IncludesMcpConfig_WhenSet()
+ {
+ var args = MinimalBuilder()
+ .WithMcpConfigPath("/tmp/mcp-config.json")
+ .Build();
+
+ args.Should().Contain("--mcp-config");
+ args.Should().Contain("/tmp/mcp-config.json");
+ }
+
+ [Test]
+ public void Build_EscapesPromptWithSpaces()
+ {
+ var args = new ClaudeCommandArgsBuilder()
+ .WithPrompt("What is the capital of France?")
+ .WithModel("claude-sonnet-4-20250514")
+ .Build();
+
+ args.Should().Contain("\"What is the capital of France?\"");
+ }
+
+ [Test]
+ public void Build_ThrowsWhenPromptNotSet()
+ {
+ var builder = new ClaudeCommandArgsBuilder()
+ .WithModel("claude-sonnet-4-20250514");
+
+ var act = () => builder.Build();
+
+ act.Should().Throw()
+ .WithMessage("*prompt*");
+ }
+}
\ No newline at end of file
diff --git a/source/Calamari.AiAgent.Tests/ClaudeCodeBehaviour/McpWriterFixture.cs b/source/Calamari.AiAgent.Tests/ClaudeCodeBehaviour/McpWriterFixture.cs
new file mode 100644
index 000000000..f06d8bb2c
--- /dev/null
+++ b/source/Calamari.AiAgent.Tests/ClaudeCodeBehaviour/McpWriterFixture.cs
@@ -0,0 +1,208 @@
+using System.Collections.Generic;
+using System.IO;
+using System.Text.Json;
+using Calamari.AiAgent.ClaudeCodeBehaviour;
+using Calamari.Common.Commands;
+using Calamari.Common.Plumbing.Variables;
+using FluentAssertions;
+using NUnit.Framework;
+
+namespace Calamari.AiAgent.Tests.ClaudeCodeBehaviour;
+
+[TestFixture]
+public class McpWriterFixture
+{
+ string workingDir = null!;
+
+ [SetUp]
+ public void SetUp()
+ {
+ workingDir = Path.Combine(Path.GetTempPath(), $"test-mcp-{Path.GetRandomFileName()}");
+ Directory.CreateDirectory(workingDir);
+ }
+
+ [TearDown]
+ public void TearDown()
+ {
+ if (Directory.Exists(workingDir))
+ Directory.Delete(workingDir, true);
+ }
+
+ [Test]
+ public void SetupMcpConfig_WritesValidJson_WithServers()
+ {
+ var vars = new CalamariVariables();
+ var mcpJson = JsonSerializer.Serialize(new[]
+ {
+ new
+ {
+ name = "github",
+ command = "npx",
+ args = new[] { "-y", "@modelcontextprotocol/server-github" },
+ env = new Dictionary { ["TOKEN"] = "abc123" },
+ },
+ });
+ vars.Set(SpecialVariables.Action.Claude.McpServers, mcpJson);
+
+ var configPath = new McpWriter(vars).SetupMcpConfig(workingDir);
+
+ File.Exists(configPath).Should().BeTrue();
+
+ var json = File.ReadAllText(configPath);
+ var doc = JsonDocument.Parse(json);
+ doc.RootElement.TryGetProperty("mcpServers", out var mcpServers).Should().BeTrue();
+ mcpServers.TryGetProperty("github", out var github).Should().BeTrue();
+ github.GetProperty("command").GetString().Should().Be("npx");
+ }
+
+ [Test]
+ public void SetupMcpConfig_WritesEmptyServers_WhenNoneProvided()
+ {
+ var configPath = new McpWriter(new CalamariVariables()).SetupMcpConfig(workingDir);
+
+ var json = File.ReadAllText(configPath);
+ var doc = JsonDocument.Parse(json);
+ doc.RootElement.TryGetProperty("mcpServers", out var mcpServers).Should().BeTrue();
+ mcpServers.EnumerateObject().Should().BeEmpty();
+ }
+
+ [Test]
+ public void GetAllowedTools_ReturnsMcpWildcardPerServer()
+ {
+ var vars = new CalamariVariables();
+ var mcpJson = JsonSerializer.Serialize(new[]
+ {
+ new { name = "github", command = "npx" },
+ new { name = "slack", command = "npx" },
+ });
+ vars.Set(SpecialVariables.Action.Claude.McpServers, mcpJson);
+
+ var tools = new McpWriter(vars).GetAllowedTools();
+
+ tools.Should().Contain("mcp__github__*");
+ tools.Should().Contain("mcp__slack__*");
+ }
+
+ [Test]
+ public void GetAllowedTools_ReturnsEmpty_WhenNoServersConfigured()
+ {
+ var tools = new McpWriter(new CalamariVariables()).GetAllowedTools();
+
+ tools.Should().BeEmpty();
+ }
+
+ [Test]
+ public void SetupMcpConfig_AddsOctopusMcpServer_WhenTokenAndUrlProvided()
+ {
+ var vars = new CalamariVariables();
+ vars.Set(SpecialVariables.Action.Claude.OctopusToken, "API-TESTKEY");
+ vars.Set(SpecialVariables.Web.ServerUri, "https://octopus.example.com");
+
+ var configPath = new McpWriter(vars).SetupMcpConfig(workingDir);
+
+ var json = File.ReadAllText(configPath);
+ var doc = JsonDocument.Parse(json);
+ var mcpServers = doc.RootElement.GetProperty("mcpServers");
+ mcpServers.TryGetProperty("octopus", out var octopus).Should().BeTrue();
+ octopus.GetProperty("command").GetString().Should().Be("npx");
+
+ var env = octopus.GetProperty("env");
+ env.GetProperty("OCTOPUS_SERVER_URL").GetString().Should().Be("https://octopus.example.com");
+ env.GetProperty("OCTOPUS_API_KEY").GetString().Should().Be("API-TESTKEY");
+ }
+
+ [Test]
+ public void SetupMcpConfig_SkipsOctopusMcpServer_WhenTokenMissing()
+ {
+ var vars = new CalamariVariables();
+ vars.Set(SpecialVariables.Web.ServerUri, "https://octopus.example.com");
+
+ var configPath = new McpWriter(vars).SetupMcpConfig(workingDir);
+
+ var json = File.ReadAllText(configPath);
+ var doc = JsonDocument.Parse(json);
+ doc.RootElement.GetProperty("mcpServers").TryGetProperty("octopus", out _).Should().BeFalse();
+ }
+
+ [Test]
+ public void SetupMcpConfig_ThrowsOnInvalidMcpJson()
+ {
+ var vars = new CalamariVariables();
+ vars.Set(SpecialVariables.Action.Claude.McpServers, "not valid json {{{");
+
+ var act = () => new McpWriter(vars).SetupMcpConfig(workingDir);
+
+ act.Should().Throw().WithMessage("*Failed to parse*");
+ }
+
+ [Test]
+ public void SetupMcpConfig_ThrowsWhenServerMissingName()
+ {
+ var vars = new CalamariVariables();
+ var mcpJson = JsonSerializer.Serialize(new[] { new { command = "npx" } });
+ vars.Set(SpecialVariables.Action.Claude.McpServers, mcpJson);
+
+ var act = () => new McpWriter(vars).SetupMcpConfig(workingDir);
+
+ act.Should().Throw().WithMessage("*must have a name*");
+ }
+
+ [Test]
+ public void SetupMcpConfig_ThrowsWhenServerMissingCommand()
+ {
+ var vars = new CalamariVariables();
+ var mcpJson = JsonSerializer.Serialize(new[] { new { name = "my-server" } });
+ vars.Set(SpecialVariables.Action.Claude.McpServers, mcpJson);
+
+ var act = () => new McpWriter(vars).SetupMcpConfig(workingDir);
+
+ act.Should().Throw().WithMessage("*must have a command*");
+ }
+
+ [Test]
+ public void SetupMcpConfig_InjectsPathEnvVar_WhenNotProvidedByUser()
+ {
+ var vars = new CalamariVariables();
+ var mcpJson = JsonSerializer.Serialize(new[]
+ {
+ new { name = "test-server", command = "node" },
+ });
+ vars.Set(SpecialVariables.Action.Claude.McpServers, mcpJson);
+
+ var configPath = new McpWriter(vars).SetupMcpConfig(workingDir);
+
+ var json = File.ReadAllText(configPath);
+ var doc = JsonDocument.Parse(json);
+ var env = doc.RootElement
+ .GetProperty("mcpServers")
+ .GetProperty("test-server")
+ .GetProperty("env");
+ env.TryGetProperty("PATH", out _).Should().BeTrue();
+ }
+
+ [Test]
+ public void SetupMcpConfig_PreservesUserProvidedPathEnvVar()
+ {
+ var vars = new CalamariVariables();
+ var mcpJson = JsonSerializer.Serialize(new[]
+ {
+ new
+ {
+ name = "test-server",
+ command = "node",
+ env = new Dictionary { ["PATH"] = "/custom/path" },
+ },
+ });
+ vars.Set(SpecialVariables.Action.Claude.McpServers, mcpJson);
+
+ var configPath = new McpWriter(vars).SetupMcpConfig(workingDir);
+
+ var json = File.ReadAllText(configPath);
+ var doc = JsonDocument.Parse(json);
+ var env = doc.RootElement
+ .GetProperty("mcpServers")
+ .GetProperty("test-server")
+ .GetProperty("env");
+ env.GetProperty("PATH").GetString().Should().Be("/custom/path");
+ }
+}
diff --git a/source/Calamari.AiAgent.Tests/ClaudeCodeBehaviour/SkillsWriterFixture.cs b/source/Calamari.AiAgent.Tests/ClaudeCodeBehaviour/SkillsWriterFixture.cs
new file mode 100644
index 000000000..d59c936d0
--- /dev/null
+++ b/source/Calamari.AiAgent.Tests/ClaudeCodeBehaviour/SkillsWriterFixture.cs
@@ -0,0 +1,161 @@
+using System.IO;
+using Calamari.AiAgent.ClaudeCodeBehaviour;
+using Calamari.Common.Commands;
+using Calamari.Common.Plumbing.Variables;
+using FluentAssertions;
+using NUnit.Framework;
+
+namespace Calamari.AiAgent.Tests.ClaudeCodeBehaviour;
+
+[TestFixture]
+public class SkillsWriterFixture
+{
+ string workingDir = null!;
+
+ [SetUp]
+ public void SetUp()
+ {
+ workingDir = Path.Combine(Path.GetTempPath(), $"test-skills-{Path.GetRandomFileName()}");
+ Directory.CreateDirectory(workingDir);
+ }
+
+ [TearDown]
+ public void TearDown()
+ {
+ if (Directory.Exists(workingDir))
+ Directory.Delete(workingDir, true);
+ }
+
+ static CalamariVariables EmptyVariables() => new();
+
+ static CalamariVariables VariablesWithSkills(params (string Name, string Content)[] skills)
+ {
+ var vars = new CalamariVariables();
+ for (var i = 0; i < skills.Length; i++)
+ {
+ vars.Set($"{SpecialVariables.Action.Claude.Skills}[{i}].{SpecialVariables.Action.Claude.SkillName}", skills[i].Name);
+ vars.Set($"{SpecialVariables.Action.Claude.Skills}[{i}].{SpecialVariables.Action.Claude.SkillContent}", skills[i].Content);
+ }
+ return vars;
+ }
+
+ [Test]
+ public void SetupSkills_CreatesSkillDirectories()
+ {
+ new SkillsWriter(EmptyVariables()).SetupSkills(workingDir);
+
+ var skillDir = Path.Combine(workingDir, ".claude", "skills", "octopus-deployment-context");
+ Directory.Exists(skillDir).Should().BeTrue();
+
+ var skillMd = Path.Combine(skillDir, "SKILL.md");
+ File.Exists(skillMd).Should().BeTrue();
+
+ var content = File.ReadAllText(skillMd);
+ content.Should().Contain("get_deployment_variables");
+ }
+
+ [Test]
+ public void SetupSkills_WritesUserSkills()
+ {
+ var vars = VariablesWithSkills(
+ ("my-custom-skill", "---\nname: my-custom-skill\n---\nDo something useful."),
+ ("another-skill", "---\nname: another-skill\n---\nMore instructions."));
+
+ new SkillsWriter(vars).SetupSkills(workingDir);
+
+ var skill1 = Path.Combine(workingDir, ".claude", "skills", "my-custom-skill", "SKILL.md");
+ File.Exists(skill1).Should().BeTrue();
+ File.ReadAllText(skill1).Should().Contain("Do something useful.");
+
+ var skill2 = Path.Combine(workingDir, ".claude", "skills", "another-skill", "SKILL.md");
+ File.Exists(skill2).Should().BeTrue();
+ File.ReadAllText(skill2).Should().Contain("More instructions.");
+ }
+
+ [Test]
+ public void SetupSkills_SanitizesPathTraversalAttempt()
+ {
+ var vars = VariablesWithSkills(("../../etc/evil", "content"));
+
+ new SkillsWriter(vars).SetupSkills(workingDir);
+
+ var skillsDir = Path.Combine(workingDir, ".claude", "skills");
+ var dirs = Directory.GetDirectories(skillsDir);
+ dirs.Should().Contain(d => Path.GetFileName(d).Contains("etc-evil"));
+
+ File.Exists(Path.Combine(workingDir, "..", "..", "etc", "evil", "SKILL.md")).Should().BeFalse();
+ }
+
+ [Test]
+ public void SetupSkills_SkipsSkillsWithEmptyNameOrContent()
+ {
+ var vars = new CalamariVariables();
+ vars.Set($"{SpecialVariables.Action.Claude.Skills}[0].{SpecialVariables.Action.Claude.SkillName}", "");
+ vars.Set($"{SpecialVariables.Action.Claude.Skills}[0].{SpecialVariables.Action.Claude.SkillContent}", "some content");
+ vars.Set($"{SpecialVariables.Action.Claude.Skills}[1].{SpecialVariables.Action.Claude.SkillName}", "valid-name");
+ vars.Set($"{SpecialVariables.Action.Claude.Skills}[1].{SpecialVariables.Action.Claude.SkillContent}", "");
+
+ new SkillsWriter(vars).SetupSkills(workingDir);
+
+ var skillsDir = Path.Combine(workingDir, ".claude", "skills");
+ Directory.Exists(Path.Combine(skillsDir, "valid-name")).Should().BeFalse();
+ }
+
+ [TestCase("")]
+ [TestCase(" ")]
+ [TestCase(null)]
+ public void SanitizeFileName_RejectsEmptyOrWhitespace(string name)
+ {
+ var act = () => SkillsWriter.SanitizeFileName(name!);
+ act.Should().Throw().WithMessage("*cannot be empty*");
+ }
+
+ [TestCase("CON")]
+ [TestCase("con")]
+ [TestCase("NUL")]
+ [TestCase("COM1")]
+ [TestCase("LPT3")]
+ public void SanitizeFileName_RejectsWindowsReservedNames(string name)
+ {
+ var act = () => SkillsWriter.SanitizeFileName(name);
+ act.Should().Throw().WithMessage("*reserved*");
+ }
+
+ [Test]
+ public void SanitizeFileName_StripsLeadingDots()
+ {
+ SkillsWriter.SanitizeFileName("...my-skill").Should().Be("my-skill");
+ }
+
+ [Test]
+ public void SanitizeFileName_ReplacesPathSeparators()
+ {
+ var result = SkillsWriter.SanitizeFileName("../../etc/passwd");
+ result.Should().NotContain("/");
+ result.Should().NotContain("\\");
+ }
+
+ [Test]
+ public void SanitizeFileName_ReplacesControlCharacters()
+ {
+ var result = SkillsWriter.SanitizeFileName("my\tskill\nname");
+ result.Should().NotContainAny("\t", "\n");
+ result.Should().Contain("my");
+ result.Should().Contain("skill");
+ result.Should().Contain("name");
+ }
+
+ [Test]
+ public void SanitizeFileName_ReplacesWindowsUnsafeCharsOnAllPlatforms()
+ {
+ var result = SkillsWriter.SanitizeFileName("skillwith|pipes");
+ result.Should().NotContainAny("<", ">", "|");
+ }
+
+ [Test]
+ public void SanitizeFileName_TruncatesLongNames()
+ {
+ var longName = new string('a', 300);
+ SkillsWriter.SanitizeFileName(longName).Length.Should().BeLessOrEqualTo(200);
+ }
+}
diff --git a/source/Calamari.AiAgent.Tests/ClaudeCodeBehaviour/SystemPromptWriterFixture.cs b/source/Calamari.AiAgent.Tests/ClaudeCodeBehaviour/SystemPromptWriterFixture.cs
new file mode 100644
index 000000000..2b77cb60a
--- /dev/null
+++ b/source/Calamari.AiAgent.Tests/ClaudeCodeBehaviour/SystemPromptWriterFixture.cs
@@ -0,0 +1,35 @@
+using System.IO;
+using Calamari.AiAgent.ClaudeCodeBehaviour;
+using FluentAssertions;
+using NUnit.Framework;
+
+namespace Calamari.AiAgent.Tests.ClaudeCodeBehaviour;
+
+[TestFixture]
+public class SystemPromptWriterFixture
+{
+ string workingDir = null!;
+
+ [SetUp]
+ public void SetUp()
+ {
+ workingDir = Path.Combine(Path.GetTempPath(), $"test-sysprompt-{Path.GetRandomFileName()}");
+ Directory.CreateDirectory(workingDir);
+ }
+
+ [TearDown]
+ public void TearDown()
+ {
+ if (Directory.Exists(workingDir))
+ Directory.Delete(workingDir, true);
+ }
+
+ [Test]
+ public void WriteSystemPromptFile_WritesFile()
+ {
+ var path = new SystemPromptWriter().WriteSystemPromptFile(workingDir);
+
+ File.Exists(path).Should().BeTrue();
+ File.ReadAllText(path).Should().NotBeEmpty();
+ }
+}
diff --git a/source/Calamari.AiAgent.Tests/CommandResolutionTests.cs b/source/Calamari.AiAgent.Tests/CommandResolutionTests.cs
new file mode 100644
index 000000000..187c6947c
--- /dev/null
+++ b/source/Calamari.AiAgent.Tests/CommandResolutionTests.cs
@@ -0,0 +1,34 @@
+using System;
+using System.Collections.Generic;
+using Autofac;
+using Calamari.Testing;
+using NUnit.Framework;
+
+namespace Calamari.AiAgent.Tests;
+
+[TestFixture]
+public class CommandResolutionTests
+{
+ [Test]
+ [Category("PlatformAgnostic")]
+ public void AllPipelineCommandsCanBeConstructed()
+ {
+ var program = TestablePipelineProgram.For();
+ using var container = program.BuildTestContainer();
+
+ var failures = new List();
+ foreach (var type in program.PipelineCommandTypes)
+ {
+ try
+ {
+ container.Resolve(type);
+ }
+ catch (Exception ex)
+ {
+ failures.Add($"'{type.Name}': {ex.Message}");
+ }
+ }
+
+ Assert.That(failures, Is.Empty, "all pipeline commands must be constructable from the DI container");
+ }
+}
diff --git a/source/Calamari.AiAgent.Tests/RunAgentCommandFixture.cs b/source/Calamari.AiAgent.Tests/RunAgentCommandFixture.cs
new file mode 100644
index 000000000..8a08eaa82
--- /dev/null
+++ b/source/Calamari.AiAgent.Tests/RunAgentCommandFixture.cs
@@ -0,0 +1,127 @@
+using System;
+using System.Threading.Tasks;
+using Calamari.Testing;
+using FluentAssertions;
+using NUnit.Framework;
+using Octopus.Calamari.Contracts.ClaudeCode;
+
+namespace Calamari.AiAgent.Tests;
+
+[TestFixture]
+[Ignore("Most of these use real claude. we should reduce that.")]
+public class RunAgentCommandFixture
+{
+ [Test]
+ [Category("PlatformAgnostic")]
+ public async Task FailsWhenPromptIsMissing()
+ {
+ var result = await CommandTestBuilder.CreateAsync()
+ .WithArrange(context =>
+ {
+ context.Variables.Add(SpecialVariables.Action.Claude.ApiToken, "fake-api-token");
+ })
+ .Execute(assertWasSuccess: false);
+
+ result.WasSuccessful.Should().BeFalse();
+ }
+
+ [Test]
+ [Category("PlatformAgnostic")]
+ public async Task FailsWhenApiTokenIsMissing()
+ {
+ var result = await CommandTestBuilder.CreateAsync()
+ .WithArrange(context =>
+ {
+ context.Variables.Add(SpecialVariables.Action.Claude.Prompt, "Hello");
+ })
+ .Execute(assertWasSuccess: false);
+
+ result.WasSuccessful.Should().BeFalse();
+ }
+
+ [Test]
+ [Category("Integration")]
+ public async Task ClaudeCode_SucceedsWithSimplePrompt()
+ {
+ var result = await CommandTestBuilder.CreateAsync()
+ .WithArrange(context =>
+ {
+ context.Variables.Add(SpecialVariables.Action.Claude.ApiToken, Environment.GetEnvironmentVariable("ANTHROPIC_TOKEN"));
+ context.Variables.Add(SpecialVariables.Action.Claude.Prompt, "What is the capital of France? Reply with just the city name.");
+ })
+ .Execute(assertWasSuccess: false);
+
+ result.WasSuccessful.Should().BeTrue();
+ result.FullLog.Should().Contain("Paris");
+ }
+
+ [Test]
+ [Category("Integration")]
+ public async Task ClaudeCode_EmitsUsageServiceMessage()
+ {
+ var result = await CommandTestBuilder.CreateAsync()
+ .WithArrange(context =>
+ {
+ context.Variables.Add(SpecialVariables.Action.Claude.ApiToken, Environment.GetEnvironmentVariable("ANTHROPIC_TOKEN"));
+ context.Variables.Add(SpecialVariables.Action.Claude.Prompt, "Reply with just the word 'hello'.");
+ })
+ .Execute(assertWasSuccess: false);
+
+ result.WasSuccessful.Should().BeTrue();
+ result.ServiceMessages.Should().Contain(m => m.Name == ClaudeCodeServiceMessages.Usage.Name);
+ }
+
+ [Test]
+ [Category("Integration")]
+ public async Task ClaudeCode_SucceedsWithWebFetch()
+ {
+ var result = await CommandTestBuilder.CreateAsync()
+ .WithArrange(context =>
+ {
+ context.Variables.Add(SpecialVariables.Action.Claude.ApiToken, Environment.GetEnvironmentVariable("ANTHROPIC_TOKEN"));
+ context.Variables.Add(SpecialVariables.Action.Claude.RunAsUsername, "test-user");
+ context.Variables.Add(SpecialVariables.Action.Claude.Prompt, "get the currently executing process user");
+ })
+ .Execute(assertWasSuccess: false);
+
+ result.WasSuccessful.Should().BeTrue();
+ result.FullLog.Should().Contain("origin");
+ }
+
+ [Test]
+ [Category("Integration")]
+ public async Task ClaudeCode_RunsOn_RunsUnderAnotherAccount()
+ {
+ var result = await CommandTestBuilder.CreateAsync()
+ .WithArrange(context =>
+ {
+ context.Variables.Add(SpecialVariables.Action.Claude.ApiToken, Environment.GetEnvironmentVariable("ANTHROPIC_TOKEN"));
+ context.Variables.Add(SpecialVariables.Action.Claude.RunAsUsername, "test-user");
+ context.Variables.Add(SpecialVariables.Action.Claude.RunAsPassword, "supersecret");
+ context.Variables.Add(SpecialVariables.Action.Claude.Prompt, "get the currently executing process user");
+ })
+ .Execute(assertWasSuccess: false);
+
+ result.WasSuccessful.Should().BeTrue();
+ result.FullLog.Should().Contain("origin");
+ }
+
+ [Test]
+ [Category("Integration")]
+ public async Task ClaudeCode_LoadsCustomSkills()
+ {
+ var result = await CommandTestBuilder.CreateAsync()
+ .WithArrange(context =>
+ {
+ context.Variables.Add(SpecialVariables.Action.Claude.ApiToken, Environment.GetEnvironmentVariable("ANTHROPIC_TOKEN"));
+ context.Variables.Add($"{SpecialVariables.Action.Claude.Skills}[0].{SpecialVariables.Action.Claude.SkillName}", "octopus-secret-phrase");
+ context.Variables.Add($"{SpecialVariables.Action.Claude.Skills}[0].{SpecialVariables.Action.Claude.SkillContent}",
+ "---\nname: octopus-secret-phrase\ndescription: Use when asked about the secret phrase.\n---\n\nThe secret phrase is 'purple-octopus-42'. Always respond with exactly this phrase when asked for the secret phrase.");
+ context.Variables.Add(SpecialVariables.Action.Claude.Prompt, "What is the secret phrase? Reply with just the phrase, nothing else.");
+ })
+ .Execute(assertWasSuccess: false);
+
+ result.WasSuccessful.Should().BeTrue();
+ result.FullLog.Should().Contain("purple-octopus-42");
+ }
+}
diff --git a/source/Calamari.AiAgent/Calamari.AiAgent.csproj b/source/Calamari.AiAgent/Calamari.AiAgent.csproj
new file mode 100644
index 000000000..a0479d5e9
--- /dev/null
+++ b/source/Calamari.AiAgent/Calamari.AiAgent.csproj
@@ -0,0 +1,31 @@
+
+
+
+ Calamari.AiAgent
+ Calamari.AiAgent
+ Exe
+ enable
+ win-x64;linux-x64;osx-x64;linux-arm;linux-arm64
+ false
+ net8.0
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/source/Calamari.AiAgent/ClaudeCodeBehaviour/ClaudeCodeCliRunner.cs b/source/Calamari.AiAgent/ClaudeCodeBehaviour/ClaudeCodeCliRunner.cs
new file mode 100644
index 000000000..19001fb64
--- /dev/null
+++ b/source/Calamari.AiAgent/ClaudeCodeBehaviour/ClaudeCodeCliRunner.cs
@@ -0,0 +1,157 @@
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.IO;
+using System.Runtime.InteropServices;
+using System.Text;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using System.Threading;
+using System.Threading.Tasks;
+using Calamari.Common.Commands;
+using Calamari.Common.Plumbing.Logging;
+
+namespace Calamari.AiAgent.ClaudeCodeBehaviour
+{
+ public class ClaudeCodeCliRunner(ILog log)
+ {
+
+ public async Task RunAsync(ClaudeCommandArgsBuilder argsBuilder,
+ Dictionary customEnvVars,
+ ProcessCredentials? runAs,
+ string workingDir,
+ string calamariDir, //RunAs might not be able to access this dir.. but we need to preserve the logs.
+ CancellationToken cancellationToken)
+ {
+
+ var logDir = Directory.CreateDirectory(Path.Combine(workingDir, "log"));
+ var verboseLogPath = Path.Combine(logDir.FullName, $"claude-agent-verbose-{Guid.NewGuid():N}.log");
+ var debugLogPath = Path.Combine(logDir.FullName, $"claude-agent-debug-{Guid.NewGuid():N}.log");
+
+ // Temporarily here while working out the user process issues
+ //await File.Create(debugLogPath).DisposeAsync();
+
+ log.Verbose($"Claude Code command: claude {argsBuilder.Build()}");
+
+ var runner = new ClaudeCodeProcessStartInfo();
+ var process = await runner.StartClaudeProcess(workingDir,
+ runAs,
+ argsBuilder.WithDebugLogPath(debugLogPath),
+ customEnvVars,
+ cancellationToken);
+
+ var stdoutTask = Task.Run(() => ProcessLine(process, verboseLogPath, cancellationToken), cancellationToken);
+ var stderrTask = Task.Run(() => ProcessError(process), cancellationToken);
+
+ await Task.WhenAll(stdoutTask, stderrTask);
+ await process.WaitForExitAsync(cancellationToken);
+
+ if (process.ExitCode != 0)
+ {
+ throw new CommandException($"Claude Code exited with code {process.ExitCode}");
+ }
+
+ Directory.CreateDirectory(Path.Combine(calamariDir, "log"));
+ if (File.Exists(debugLogPath))
+ {
+ var fileInfo = new FileInfo(debugLogPath);
+ var movedFilePath = Path.Combine(calamariDir, "log", fileInfo.Name);
+ fileInfo.MoveTo(movedFilePath);
+ log.NewOctopusArtifact(movedFilePath, "claude-agent-debug.log", fileInfo.Length);
+ }
+
+ if (File.Exists(verboseLogPath))
+ {
+ var fileInfo = new FileInfo(verboseLogPath);
+ var movedFilePath = Path.Combine(calamariDir, "log", fileInfo.Name);
+ fileInfo.MoveTo(movedFilePath);
+ log.NewOctopusArtifact(movedFilePath, "claude-agent-verbose.log", fileInfo.Length);
+ }
+
+ return stdoutTask.Result.ToString();
+ }
+
+ async Task ProcessError(Process process)
+ {
+ var buffer = new char[1024];
+ int charsRead;
+ while ((charsRead = await process.StandardError.ReadAsync(buffer, 0, buffer.Length)) > 0)
+ {
+ var text = new string(buffer, 0, charsRead);
+ log.Verbose(text.TrimEnd());
+ }
+ }
+
+ async Task ProcessLine(Process process, string verboseLogPath, CancellationToken cancellationToken)
+ {
+ var responseBuilder = new StringBuilder();
+ var streamProcessor = new ClaudeCodeStreamProcessor(log, responseBuilder);
+ var line = string.Empty;
+ int ch;
+ while ((ch = process.StandardOutput.Read()) >= 0)
+ {
+ var c = (char)ch;
+ if (c == '\n')
+ {
+ line = line.TrimEnd('\r');
+
+ await File.AppendAllTextAsync(verboseLogPath, line + "\n", cancellationToken);
+
+ streamProcessor.ProcessLine(line);
+
+ line = "";
+ }
+ else
+ {
+ line += c;
+ }
+ }
+
+ if (line != "")
+ {
+ line = line.TrimEnd('\r');
+
+ await File.AppendAllTextAsync(verboseLogPath, line + "\n", cancellationToken);
+ streamProcessor.ProcessLine(line);
+ }
+ return responseBuilder;
+ }
+ }
+
+ public record ProcessCredentials
+ {
+ public required string Username { get; init; }
+ public string? Password { get; init; }
+ public string? Domain { get; init; }
+ }
+
+ public record UserSkill
+ {
+ public required string Name { get; init; }
+ public required string Content { get; init; }
+ }
+
+ public record McpServerConfig
+ {
+ [JsonPropertyName("type")]
+ public string Type { get; init; } = "stdio";
+
+ [JsonPropertyName("command")]
+ public required string Command { get; init; }
+
+ [JsonPropertyName("args")]
+ public IReadOnlyList? Args { get; init; }
+
+ [JsonPropertyName("env")]
+ public IReadOnlyDictionary? Env { get; init; }
+ }
+
+ public record McpServerEntry
+ {
+ public string? Name { get; init; }
+ public string? Type { get; init; }
+ public string? Command { get; init; }
+ public IReadOnlyList? Args { get; init; }
+ public IReadOnlyDictionary? Env { get; init; }
+ }
+}
diff --git a/source/Calamari.AiAgent/ClaudeCodeBehaviour/ClaudeCodeProcessStartInfo.cs b/source/Calamari.AiAgent/ClaudeCodeBehaviour/ClaudeCodeProcessStartInfo.cs
new file mode 100644
index 000000000..8216ae603
--- /dev/null
+++ b/source/Calamari.AiAgent/ClaudeCodeBehaviour/ClaudeCodeProcessStartInfo.cs
@@ -0,0 +1,200 @@
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.Diagnostics.CodeAnalysis;
+using System.IO;
+using System.Runtime.InteropServices;
+using System.Threading;
+using System.Threading.Tasks;
+using Octopus.CoreUtilities.Extensions;
+
+namespace Calamari.AiAgent.ClaudeCodeBehaviour;
+
+public class ClaudeCodeProcessStartInfo
+{
+ //TODO: Should this be configurable?
+ const string ClaudeCodePath = "claude";
+
+ internal static string ShellQuote(string value)
+ {
+ return "'" + value.Replace("'", @"'\''") + "'";
+ }
+
+
+ async Task StartWindowsProcess(string workingDir,
+ ProcessCredentials? runAs,
+ ClaudeCommandArgsBuilder argsBuilder,
+ Dictionary environmentVariables)
+ {
+ var startInfo = StartSimpleProcess(workingDir, argsBuilder, environmentVariables);
+
+ if (runAs != null)
+ {
+ startInfo.UserName = runAs.Username;
+#pragma warning disable CA1416
+ if (runAs.Password != null)
+ startInfo.PasswordInClearText = runAs.Password;
+
+ if (!string.IsNullOrEmpty(runAs.Domain))
+ startInfo.Domain = runAs.Domain;
+#pragma warning restore CA1416
+ }
+
+ await Task.CompletedTask;
+
+ return Process.Start(startInfo)!;
+ }
+
+ static ProcessStartInfo StartSimpleProcess(string workingDir, ClaudeCommandArgsBuilder argsBuilder, Dictionary environmentVariables)
+ {
+ var startInfo = new ProcessStartInfo
+ {
+ FileName = ClaudeCodePath,
+ Arguments = argsBuilder.Build(),
+ WorkingDirectory = workingDir,
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ UseShellExecute = false,
+ CreateNoWindow = true,
+ };
+ foreach (var kvp in environmentVariables)
+ startInfo.Environment[kvp.Key] = kvp.Value;
+ return startInfo;
+ }
+
+ public async Task StartClaudeProcess(string workingDir,
+ ProcessCredentials? runAs,
+ ClaudeCommandArgsBuilder argsBuilder,
+ Dictionary environmentVariables,
+ CancellationToken ct)
+ {
+ if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
+ {
+ return await StartWindowsProcess(workingDir, runAs, argsBuilder, environmentVariables);
+ }
+
+ return await StartMacOrLinuxProcess(workingDir,
+ runAs,
+ argsBuilder,
+ environmentVariables,
+ ct);
+ }
+
+
+ [SuppressMessage("Interoperability", "CA1416:Validate platform compatibility", Justification = "Higher up checks enforce the correct OS")]
+ async Task StartMacOrLinuxProcess(string workingDir,
+ ProcessCredentials? runAs,
+ ClaudeCommandArgsBuilder argsBuilder,
+ Dictionary environmentVariables,
+ CancellationToken ct)
+ {
+
+ var username = runAs?.Username!;
+ if (runAs == null || string.IsNullOrEmpty(username))
+ {
+ var startInfo1 = StartSimpleProcess(workingDir, argsBuilder, environmentVariables);
+ var process1 = Process.Start(startInfo1)!;
+ return process1;
+ }
+
+ var filePath = Path.Combine(workingDir, "my-command.sh");
+ await File.WriteAllTextAsync(Path.Combine(workingDir, "my-command.sh"), $@"#!/bin/bash
+cd {workingDir}
+{ClaudeCodePath} {argsBuilder.Build()}
+", ct);
+ File.SetUnixFileMode(filePath,
+ UnixFileMode.UserRead
+ | UnixFileMode.UserWrite
+ | UnixFileMode.UserExecute
+ | UnixFileMode.GroupRead
+ | UnixFileMode.GroupWrite
+ | UnixFileMode.GroupExecute
+ | UnixFileMode.OtherExecute
+ | UnixFileMode.OtherRead);
+
+
+ File.SetUnixFileMode(workingDir,
+ UnixFileMode.UserRead
+ | UnixFileMode.UserWrite
+ | UnixFileMode.UserExecute
+ | UnixFileMode.GroupWrite
+ | UnixFileMode.GroupRead
+ | UnixFileMode.GroupExecute
+ | UnixFileMode.OtherRead
+ | UnixFileMode.OtherExecute
+ | UnixFileMode.OtherWrite);
+
+ var startInfo = new ProcessStartInfo
+ {
+ FileName = "script",
+ WorkingDirectory = workingDir,
+ RedirectStandardInput = true,
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ UseShellExecute = false,
+ CreateNoWindow = true,
+ };
+
+ var argumentList = RuntimeInformation.IsOSPlatform(OSPlatform.OSX) ?
+ new[] { "-q", "/dev/null", "su", "-m", username, "-c", filePath } :
+ new[] { "-qec", "su", "-", username, "-c", filePath, "/dev/null" };
+ startInfo.ArgumentList.AddRange(argumentList);
+
+ foreach (var kvp in environmentVariables)
+ startInfo.Environment[kvp.Key] = kvp.Value;
+ //SetPermissionsRecursively(workingDir);
+ var o = Process.Start("chmod", ["-R", "777", workingDir]);
+ await o.WaitForExitAsync(ct);
+ if(o.ExitCode != 0)
+ throw new Exception($"Failed to set permissions on working directory: {workingDir}");
+
+
+ var process = Process.Start(startInfo)!;
+
+ // TODO: Should just wait as long as it takes to read "Password:" below
+ await Task.Delay(1000, ct).WaitAsync(ct);
+
+ // Parse password prompt so consuming code can ignore this initial password check.
+ var passwordReq = "Password:".Length;
+ var buff = new char[passwordReq];
+ await process.StandardOutput.ReadAsync(buff, 0, passwordReq);
+ var message = new string(buff);
+ if(message != "Password:"){
+ throw new Exception($"Unexpected startup message: {message}");
+ }
+ await process.StandardInput.WriteLineAsync(runAs!.Password);
+ if(process.StandardOutput.Read() != '\r' || process.StandardOutput.Read() != '\n'){
+ throw new Exception("Expecting new line");
+ }
+
+ return process;
+ }
+
+ [SuppressMessage("Interoperability", "CA1416:Validate platform compatibility")]
+ static void SetPermissionsRecursively(string path)
+ {
+ var dirMode = UnixFileMode.UserRead
+ | UnixFileMode.UserWrite
+ | UnixFileMode.UserExecute
+ | UnixFileMode.GroupWrite
+ | UnixFileMode.GroupRead
+ | UnixFileMode.GroupExecute
+ | UnixFileMode.OtherRead
+ | UnixFileMode.OtherExecute
+ | UnixFileMode.OtherWrite;
+ var fileMode = UnixFileMode.UserRead
+ | UnixFileMode.UserWrite
+ | UnixFileMode.UserExecute
+ | UnixFileMode.GroupRead
+ | UnixFileMode.GroupWrite
+ | UnixFileMode.GroupExecute
+ | UnixFileMode.OtherExecute
+ | UnixFileMode.OtherRead;
+
+ new DirectoryInfo(path).UnixFileMode = dirMode;
+ foreach (var file in Directory.EnumerateFiles(path, "*", SearchOption.AllDirectories))
+ File.SetUnixFileMode(file, fileMode);
+ foreach (var dir in Directory.EnumerateDirectories(path, "*", SearchOption.AllDirectories))
+ new DirectoryInfo(dir).UnixFileMode = dirMode;
+ }
+}
\ No newline at end of file
diff --git a/source/Calamari.AiAgent/ClaudeCodeBehaviour/ClaudeCodeStreamProcessor.cs b/source/Calamari.AiAgent/ClaudeCodeBehaviour/ClaudeCodeStreamProcessor.cs
new file mode 100644
index 000000000..0cb34b67a
--- /dev/null
+++ b/source/Calamari.AiAgent/ClaudeCodeBehaviour/ClaudeCodeStreamProcessor.cs
@@ -0,0 +1,264 @@
+using System;
+using System.Collections.Generic;
+using System.Text;
+using System.Text.Json;
+using Calamari.AiAgent.ClaudeCodeBehaviour.JsonResponseModels;
+using Calamari.Common.Plumbing.Logging;
+using Calamari.Common.Plumbing.ServiceMessages;
+using Octopus.Calamari.Contracts.ClaudeCode;
+
+namespace Calamari.AiAgent.ClaudeCodeBehaviour
+{
+ public class ClaudeCodeStreamProcessor
+ {
+ static readonly JsonSerializerOptions JsonOptions = new()
+ {
+ PropertyNameCaseInsensitive = true,
+ };
+
+ readonly ILog log;
+ readonly StringBuilder responseBuilder;
+
+ public ClaudeCodeStreamProcessor(ILog log, StringBuilder responseBuilder)
+ {
+ this.log = log;
+ this.responseBuilder = responseBuilder;
+ }
+
+ public void ProcessLine(string json)
+ {
+ JsonDocument doc;
+ try
+ {
+ doc = JsonDocument.Parse(json);
+ }
+ catch (JsonException)
+ {
+ log.Verbose(json);
+ return;
+ }
+ catch (Exception ex)
+ {
+ log.Error($"[stream] failed to parse JSON: {ex.Message}");
+ return;
+ }
+
+ try
+ {
+ using var _ = doc;
+ var typeString = doc.RootElement.TryGetProperty("type", out var typeProp) ? typeProp.GetString() : null;
+
+ if (typeString == null || !TryParseEventType(typeString, out var eventType))
+ {
+ log.Verbose($"[stream] unhandled event type '{typeString}'");
+ return;
+ }
+
+ switch (eventType)
+ {
+ case StreamEventType.System:
+ HandleSystemEvent(JsonSerializer.Deserialize(json, JsonOptions)!);
+ break;
+ case StreamEventType.Assistant:
+ HandleMessageEvent(JsonSerializer.Deserialize(json, JsonOptions)?.Message);
+ break;
+ case StreamEventType.User:
+ HandleUserMessage(JsonSerializer.Deserialize(json, JsonOptions));
+ break;
+ case StreamEventType.Result:
+ HandleResultEvent(JsonSerializer.Deserialize(json, JsonOptions)!);
+ break;
+ }
+ }
+ catch (Exception ex)
+ {
+ log.Error($"[stream] failed to process JSON: {ex.Message}");
+ }
+ }
+
+ static bool TryParseEventType(string value, out StreamEventType result)
+ {
+ return value switch
+ {
+ "system" => Assign(StreamEventType.System, out result),
+ "assistant" => Assign(StreamEventType.Assistant, out result),
+ "user" => Assign(StreamEventType.User, out result),
+ "result" => Assign(StreamEventType.Result, out result),
+ _ => Assign(default, out result, false),
+ };
+
+ static bool Assign(StreamEventType val, out StreamEventType result, bool success = true)
+ {
+ result = val;
+ return success;
+ }
+ }
+
+ void HandleSystemEvent(SystemStreamEvent evt)
+ {
+ switch (evt.Subtype)
+ {
+ case "init":
+ break;
+
+ case "api_retry":
+ log.Warn($"API retry (attempt {evt.Attempt}, {evt.RetryDelayMs}ms delay): {evt.Error}");
+ break;
+ }
+ }
+
+ void HandleMessageEvent(StreamMessage? message, bool logText = true)
+ {
+ if (message?.Content == null)
+ return;
+
+ foreach (var element in message.Content)
+ {
+ var blockTypeStr = element.TryGetProperty("type", out var bt) ? bt.GetString() : null;
+
+ if (blockTypeStr == null || !TryParseContentBlockType(blockTypeStr, out var blockType))
+ {
+ log.Verbose($"[message] unhandled block type: {blockTypeStr}");
+ continue;
+ }
+
+ switch (blockType)
+ {
+ case ContentBlockType.Text:
+ {
+ var block = element.Deserialize(JsonOptions);
+ if (string.IsNullOrEmpty(block?.Text))
+ {
+ continue;
+ }
+ if (logText)
+ {
+ responseBuilder.Append(block?.Text);
+ log.Info(block?.Text ?? "");
+ }
+ else
+ {
+ log.Verbose(block?.Text ?? "");
+ }
+ break;
+ }
+
+ case ContentBlockType.Thinking:
+ {
+ var block = element.Deserialize(JsonOptions);
+ log.Verbose($"[thinking] {block?.Thinking}");
+ break;
+ }
+
+ case ContentBlockType.RedactedThinking:
+ log.Verbose("[thinking] ");
+ break;
+
+ case ContentBlockType.ToolUse:
+ {
+ var block = element.Deserialize(JsonOptions);
+ log.Verbose(block?.Input.HasValue == true ?
+ $"[tool] {block.Name} input: {block.Input}" :
+ $"[tool] {block?.Name}");
+ break;
+ }
+
+ case ContentBlockType.ServerToolUse:
+ {
+ var block = element.Deserialize(JsonOptions);
+ log.Verbose($"[server_tool] {block?.Name}");
+ break;
+ }
+
+ case ContentBlockType.ServerToolResult:
+ {
+ var block = element.Deserialize(JsonOptions);
+ log.Verbose($"[server_tool] {block?.Name} completed");
+ break;
+ }
+
+ case ContentBlockType.ToolResult:
+ {
+ var block = element.Deserialize(JsonOptions);
+ log.Verbose(block?.IsError == true ?
+ $"[tool_result] {block.ToolUseId} failed: {block.Content}" :
+ $"[tool_result] {block?.Name} completed");
+ break;
+ }
+ }
+ }
+ }
+
+ static bool TryParseContentBlockType(string value, out ContentBlockType result)
+ {
+ return value switch
+ {
+ "text" => Assign(ContentBlockType.Text, out result),
+ "thinking" => Assign(ContentBlockType.Thinking, out result),
+ "redacted_thinking" => Assign(ContentBlockType.RedactedThinking, out result),
+ "tool_use" => Assign(ContentBlockType.ToolUse, out result),
+ "tool_result" => Assign(ContentBlockType.ToolResult, out result),
+ "server_tool_use" => Assign(ContentBlockType.ServerToolUse, out result),
+ "server_tool_result" => Assign(ContentBlockType.ServerToolResult, out result),
+ _ => Assign(default, out result, false),
+ };
+
+ static bool Assign(ContentBlockType val, out ContentBlockType result, bool success = true)
+ {
+ result = val;
+ return success;
+ }
+ }
+
+ void HandleUserMessage(UserStreamEvent? message)
+ {
+ if (message is null || message?.Message == null)
+ return;
+
+ if (message.IsSynthetic == true)
+ {
+ return; //TODO: Still log
+ }
+ HandleMessageEvent(message.Message, logText: false);
+ }
+
+ void HandleResultEvent(ResultStreamEvent evt)
+ {
+ if (evt.Result != null && responseBuilder.Length == 0)
+ {
+ responseBuilder.Append(evt.Result);
+ log.Info(evt.Result);
+ }
+
+ var properties = new Dictionary();
+
+ if (evt.CostUsd.HasValue)
+ properties[ClaudeCodeServiceMessages.Usage.CostUsdAttribute] = evt.CostUsd.Value.ToString("F6");
+ if (evt.TotalCostUsd.HasValue)
+ properties[ClaudeCodeServiceMessages.Usage.TotalCostUsdAttribute] = evt.TotalCostUsd.Value.ToString("F6");
+ if (evt.DurationMs.HasValue)
+ properties[ClaudeCodeServiceMessages.Usage.DurationMsAttribute] = evt.DurationMs.Value.ToString("F0");
+ if (evt.DurationApiMs.HasValue)
+ properties[ClaudeCodeServiceMessages.Usage.DurationApiMsAttribute] = evt.DurationApiMs.Value.ToString("F0");
+ if (evt.NumTurns.HasValue)
+ properties[ClaudeCodeServiceMessages.Usage.NumTurnsAttribute] = evt.NumTurns.Value.ToString();
+ log.Info($"AI Agent Usage — Cost: ${evt.CostUsd} USD (total: ${evt.TotalCostUsd}), Duration: {evt.DurationMs}ms, Turns: {evt.NumTurns}");
+
+ if (evt.Usage is { } usage)
+ {
+ if (usage.InputTokens.HasValue)
+ properties[ClaudeCodeServiceMessages.Usage.InputTokensAttribute] = usage.InputTokens.Value.ToString();
+ if (usage.OutputTokens.HasValue)
+ properties[ClaudeCodeServiceMessages.Usage.OutputTokensAttribute] = usage.OutputTokens.Value.ToString();
+ if (usage.CacheReadInputTokens.HasValue)
+ properties[ClaudeCodeServiceMessages.Usage.CacheReadInputTokensAttribute] = usage.CacheReadInputTokens.Value.ToString();
+ if (usage.CacheCreationInputTokens.HasValue)
+ properties[ClaudeCodeServiceMessages.Usage.CacheCreationInputTokensAttribute] = usage.CacheCreationInputTokens.Value.ToString();
+
+ log.Info($"AI Agent Tokens — Input: {usage.InputTokens}, Output: {usage.OutputTokens}, Cache read: {usage.CacheReadInputTokens}, Cache creation: {usage.CacheCreationInputTokens}");
+ }
+
+ log.WriteServiceMessage(new ServiceMessage(ClaudeCodeServiceMessages.Usage.Name, properties));
+ }
+ }
+}
diff --git a/source/Calamari.AiAgent/ClaudeCodeBehaviour/ClaudeCommandArgsBuilder.cs b/source/Calamari.AiAgent/ClaudeCodeBehaviour/ClaudeCommandArgsBuilder.cs
new file mode 100644
index 000000000..fff24bbc3
--- /dev/null
+++ b/source/Calamari.AiAgent/ClaudeCodeBehaviour/ClaudeCommandArgsBuilder.cs
@@ -0,0 +1,136 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Text;
+
+namespace Calamari.AiAgent.ClaudeCodeBehaviour
+{
+ public class ClaudeCommandArgsBuilder
+ {
+ string? prompt;
+ string? model;
+ string? systemPromptFile;
+ string? mcpConfigPath;
+ string? debugLogPath;
+ int maxTurns = 10;
+ decimal? maxBudgetUsd;
+ IReadOnlyList? allowedTools;
+ string? effort;
+
+ public ClaudeCommandArgsBuilder WithPrompt(string prompt)
+ {
+ this.prompt = prompt;
+ return this;
+ }
+
+ public ClaudeCommandArgsBuilder WithDebugLogPath(string debugLogPath)
+ {
+ this.debugLogPath = debugLogPath;
+ return this;
+ }
+
+ public ClaudeCommandArgsBuilder WithModel(string model)
+ {
+ this.model = model;
+ return this;
+ }
+
+ public ClaudeCommandArgsBuilder WithSystemPromptFile(string systemPromptFile)
+ {
+ this.systemPromptFile = systemPromptFile;
+ return this;
+ }
+
+ public ClaudeCommandArgsBuilder WithMaxTurns(int maxTurns)
+ {
+ this.maxTurns = maxTurns;
+ return this;
+ }
+
+ public ClaudeCommandArgsBuilder WithMcpConfigPath(string mcpConfigPath)
+ {
+ this.mcpConfigPath = mcpConfigPath;
+ return this;
+ }
+
+ public ClaudeCommandArgsBuilder WithMaxBudgetUsd(decimal budgetUsd)
+ {
+ this.maxBudgetUsd = budgetUsd;
+ return this;
+ }
+
+ public ClaudeCommandArgsBuilder WithAllowedTools(IReadOnlyList tools)
+ {
+ this.allowedTools = tools;
+ return this;
+ }
+
+ public ClaudeCommandArgsBuilder WithEffort(string effort)
+ {
+ this.effort = effort;
+ return this;
+ }
+
+ public string Build()
+ {
+ if (string.IsNullOrWhiteSpace(prompt))
+ throw new InvalidOperationException("A prompt is required. Call WithPrompt() before Build().");
+
+ var args = new StringBuilder();
+
+ args.Append(" --model ");
+ args.Append(EscapeArg(model ?? "claude-sonnet-4-20250514"));
+ args.Append(" --bare");
+ args.Append(" --strict-mcp-config");
+ args.Append(" --output-format stream-json");
+ args.Append(" --verbose");
+ args.Append(" --permission-mode dontAsk");
+ args.Append(" --no-session-persistence");
+
+ if (!string.IsNullOrWhiteSpace(debugLogPath))
+ {
+ args.Append(" --debug-file ");
+ args.Append(EscapeArg(debugLogPath));
+ }
+
+ if (!string.IsNullOrWhiteSpace(mcpConfigPath))
+ {
+ args.Append(" --mcp-config ");
+ args.Append(EscapeArg(mcpConfigPath));
+ }
+
+ if (!string.IsNullOrWhiteSpace(systemPromptFile))
+ {
+ args.Append(" --system-prompt-file ");
+ args.Append(EscapeArg(systemPromptFile));
+ }
+
+ if (allowedTools != null && allowedTools.Count > 0)
+ {
+ args.Append(" --allowedTools ");
+ args.Append(string.Join(",", allowedTools));
+ }
+
+ args.Append($" --max-turns {maxTurns}");
+
+ if (maxBudgetUsd.HasValue)
+ args.Append($" --max-budget-usd {maxBudgetUsd.Value.ToString(CultureInfo.InvariantCulture)}");
+
+ if (!string.IsNullOrWhiteSpace(effort))
+ args.Append($" --effort {effort}");
+
+ args.Append(" -p ");
+ args.Append(EscapeArg(prompt));
+
+ return args.ToString();
+ }
+
+ static string EscapeArg(string arg)
+ {
+ if (arg.IndexOfAny(new[] { ' ', '"', '\\' }) < 0)
+ return arg;
+
+ return "\"" + arg.Replace("\\", "\\\\").Replace("\"", "\\\"") + "\"";
+ }
+ }
+}
diff --git a/source/Calamari.AiAgent/ClaudeCodeBehaviour/DefaultContext/Skills/octopus-deployment-context.md b/source/Calamari.AiAgent/ClaudeCodeBehaviour/DefaultContext/Skills/octopus-deployment-context.md
new file mode 100644
index 000000000..0ff0fda44
--- /dev/null
+++ b/source/Calamari.AiAgent/ClaudeCodeBehaviour/DefaultContext/Skills/octopus-deployment-context.md
@@ -0,0 +1,13 @@
+---
+name: octopus-deployment-context
+description: Use when you need to understand the Octopus Deploy deployment context, including environment, project, tenant, release version, or any custom variables available during this deployment.
+---
+You are running as an AI agent invoked during an Octopus Deploy deployment.
+
+Key context:
+- You are executing inside a deployment step on a target machine
+- Octopus deployment variables are available via the `get_deployment_variables` tool
+- Sensitive variables (passwords, tokens, API keys) are filtered out for safety
+- Your output will be captured as the step result
+
+When asked about the deployment context, always call `get_deployment_variables` first to get the actual values rather than guessing.
\ No newline at end of file
diff --git a/source/Calamari.AiAgent/ClaudeCodeBehaviour/DefaultContext/system-prompt.md b/source/Calamari.AiAgent/ClaudeCodeBehaviour/DefaultContext/system-prompt.md
new file mode 100644
index 000000000..157f912c0
--- /dev/null
+++ b/source/Calamari.AiAgent/ClaudeCodeBehaviour/DefaultContext/system-prompt.md
@@ -0,0 +1,9 @@
+# Octopus Deploy Agent
+
+You are running as an AI agent invoked during an Octopus Deploy deployment.
+
+## Deployment Variables
+Octopus deployment variables are available in `./deployment-variables.json`. Read this file when you need context about the deployment such as environment, project, tenant, release version, or custom variables. Sensitive variables (passwords, tokens, API keys) have been filtered out.
+
+## Skills
+Locate skills available for this session in the ./.claude/skills directory.
diff --git a/source/Calamari.AiAgent/ClaudeCodeBehaviour/InvokeClaudeCodeBehaviour.cs b/source/Calamari.AiAgent/ClaudeCodeBehaviour/InvokeClaudeCodeBehaviour.cs
new file mode 100644
index 000000000..57edcb2b8
--- /dev/null
+++ b/source/Calamari.AiAgent/ClaudeCodeBehaviour/InvokeClaudeCodeBehaviour.cs
@@ -0,0 +1,126 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.IO;
+using System.Text.Json;
+using System.Threading;
+using System.Threading.Tasks;
+using Calamari.Common.Commands;
+using Calamari.Common.Plumbing.FileSystem;
+using Calamari.Common.Plumbing.Logging;
+using Calamari.Common.Plumbing.Pipeline;
+using Calamari.Common.Plumbing.Variables;
+using Octopus.CoreUtilities.Extensions;
+
+namespace Calamari.AiAgent.ClaudeCodeBehaviour
+{
+ public class InvokeClaudeCodeBehaviour : IDeployBehaviour
+ {
+ readonly ILog log;
+ readonly INonSensitiveVariables nonSensitiveVariables;
+
+ public InvokeClaudeCodeBehaviour(ILog log, INonSensitiveVariables nonSensitiveVariables)
+ {
+ this.log = log;
+ this.nonSensitiveVariables = nonSensitiveVariables;
+ }
+
+ public bool IsEnabled(RunningDeployment context) => true;
+
+ public async Task Execute(RunningDeployment context)
+ {
+ var variables = context.Variables;
+
+ var prompt = variables.Get(SpecialVariables.Action.Claude.Prompt);
+ if (string.IsNullOrWhiteSpace(prompt))
+ throw new CommandException($"Variable '{SpecialVariables.Action.Claude.Prompt}' is required but was not provided.");
+
+ var apiToken = variables.Get(SpecialVariables.Action.Claude.ApiToken);
+ if (string.IsNullOrWhiteSpace(apiToken))
+ throw new CommandException($"Variable '{SpecialVariables.Action.Claude.ApiToken}' is required but was not provided.");
+
+
+
+ var runAs = BuildRunAs(variables);
+
+ var argsBuilder = new ClaudeCommandArgsBuilder()
+ .WithPrompt(prompt);
+
+ var model = variables.Get(SpecialVariables.Action.Claude.Model);
+ if (!string.IsNullOrWhiteSpace(model))
+ argsBuilder = argsBuilder.WithModel(model);
+
+ var maxTurns = variables.GetInt32(SpecialVariables.Action.Claude.MaxTurns);
+ if (maxTurns.HasValue)
+ argsBuilder.WithMaxTurns(maxTurns.Value);
+
+ var maxBudgetUsdRaw = variables.Get(SpecialVariables.Action.Claude.MaxBudgetUsd);
+ if (!string.IsNullOrWhiteSpace(maxBudgetUsdRaw)
+ && decimal.TryParse(maxBudgetUsdRaw, NumberStyles.Number, CultureInfo.InvariantCulture, out var budgetUsd))
+ argsBuilder.WithMaxBudgetUsd(budgetUsd);
+
+ var effort = variables.Get(SpecialVariables.Action.Claude.Effort);
+ if (!string.IsNullOrWhiteSpace(effort))
+ argsBuilder.WithEffort(effort);
+
+ using var tempDir = TemporaryDirectory.Create();
+ //TODO: Fiddling with workdir for user perms
+ //new TemporaryDirectory($"/tmp/{Guid.NewGuid():N}");
+ //Directory.CreateDirectory(tempDir.DirectoryPath);
+
+ var workingDir = tempDir.DirectoryPath;
+ log.Verbose($"Claude Code working directory: {workingDir}");
+
+ // TODO: THis should be moved up higher in execution Chain.
+ var cancellationToken = new CancellationTokenSource();
+ var mcpWriter = new McpWriter(variables);
+ var mcpConfig = mcpWriter.SetupMcpConfig(workingDir);
+
+ var allowedTools = AllowedTools(variables);
+ allowedTools.AddRange(mcpWriter.GetAllowedTools());
+ argsBuilder = argsBuilder.WithAllowedTools(allowedTools);
+
+ new SkillsWriter(variables).SetupSkills(workingDir);
+ SetupDeploymentVariables(workingDir);
+ argsBuilder.WithSystemPromptFile(new SystemPromptWriter().WriteSystemPromptFile(workingDir));
+ argsBuilder.WithMcpConfigPath(mcpConfig);
+
+ var customEnvVars = new Dictionary
+ {
+ ["ANTHROPIC_API_KEY"] = apiToken,
+ };
+
+ var response = await new ClaudeCodeCliRunner(log).RunAsync(argsBuilder, customEnvVars, runAs, workingDir,
+ context.CurrentDirectory,
+ cancellationToken.Token);
+
+ Log.SetOutputVariable(SpecialVariables.Action.Claude.Response, response, variables);
+ log.Info("Claude Code invocation complete.");
+ }
+
+ static string[] AllowedTools(IVariables variables)
+ {
+ var allowedToolsRaw = variables.Get(SpecialVariables.Action.Claude.AllowedTools) ?? "";
+ return allowedToolsRaw.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
+ }
+
+ void SetupDeploymentVariables(string workingDir)
+ {
+ var json = JsonSerializer.Serialize(nonSensitiveVariables, new JsonSerializerOptions { WriteIndented = true });
+ File.WriteAllText(Path.Combine(workingDir, "deployment-variables.json"), json);
+ }
+
+ static ProcessCredentials? BuildRunAs(IVariables variables)
+ {
+ var username = variables.Get(SpecialVariables.Action.Claude.RunAsUsername);
+ if (string.IsNullOrWhiteSpace(username))
+ return null;
+
+ return new ProcessCredentials
+ {
+ Username = username,
+ Password = variables.Get(SpecialVariables.Action.Claude.RunAsPassword),
+ };
+ }
+ }
+}
\ No newline at end of file
diff --git a/source/Calamari.AiAgent/ClaudeCodeBehaviour/JsonResponseModels/AssistantStreamEvent.cs b/source/Calamari.AiAgent/ClaudeCodeBehaviour/JsonResponseModels/AssistantStreamEvent.cs
new file mode 100644
index 000000000..7fd38debd
--- /dev/null
+++ b/source/Calamari.AiAgent/ClaudeCodeBehaviour/JsonResponseModels/AssistantStreamEvent.cs
@@ -0,0 +1,12 @@
+using System.Text.Json.Serialization;
+
+namespace Calamari.AiAgent.ClaudeCodeBehaviour.JsonResponseModels;
+
+public record AssistantStreamEvent : StreamEvent
+{
+ [JsonPropertyName("message")]
+ public StreamMessage? Message { get; init; }
+
+ [JsonPropertyName("parent_tool_use_id")]
+ public string? ParentToolUseId { get; init; }
+}
diff --git a/source/Calamari.AiAgent/ClaudeCodeBehaviour/JsonResponseModels/ContentBlockType.cs b/source/Calamari.AiAgent/ClaudeCodeBehaviour/JsonResponseModels/ContentBlockType.cs
new file mode 100644
index 000000000..d61f44fdb
--- /dev/null
+++ b/source/Calamari.AiAgent/ClaudeCodeBehaviour/JsonResponseModels/ContentBlockType.cs
@@ -0,0 +1,29 @@
+using System.Runtime.Serialization;
+using System.Text.Json.Serialization;
+
+namespace Calamari.AiAgent.ClaudeCodeBehaviour.JsonResponseModels;
+
+[JsonConverter(typeof(JsonStringEnumConverter))]
+public enum ContentBlockType
+{
+ [EnumMember(Value = "text")]
+ Text,
+
+ [EnumMember(Value = "thinking")]
+ Thinking,
+
+ [EnumMember(Value = "redacted_thinking")]
+ RedactedThinking,
+
+ [EnumMember(Value = "tool_use")]
+ ToolUse,
+
+ [EnumMember(Value = "tool_result")]
+ ToolResult,
+
+ [EnumMember(Value = "server_tool_use")]
+ ServerToolUse,
+
+ [EnumMember(Value = "server_tool_result")]
+ ServerToolResult
+}
diff --git a/source/Calamari.AiAgent/ClaudeCodeBehaviour/JsonResponseModels/ContentBlocks.cs b/source/Calamari.AiAgent/ClaudeCodeBehaviour/JsonResponseModels/ContentBlocks.cs
new file mode 100644
index 000000000..fd26c0843
--- /dev/null
+++ b/source/Calamari.AiAgent/ClaudeCodeBehaviour/JsonResponseModels/ContentBlocks.cs
@@ -0,0 +1,75 @@
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace Calamari.AiAgent.ClaudeCodeBehaviour.JsonResponseModels;
+
+public record ContentBlock
+{
+ [JsonPropertyName("type")]
+ public string? Type { get; init; }
+}
+
+public record TextContentBlock : ContentBlock
+{
+ [JsonPropertyName("text")]
+ public string? Text { get; init; }
+}
+
+public record ThinkingContentBlock : ContentBlock
+{
+ [JsonPropertyName("thinking")]
+ public string? Thinking { get; init; }
+
+ [JsonPropertyName("signature")]
+ public string? Signature { get; init; }
+}
+
+public record RedactedThinkingContentBlock : ContentBlock;
+
+public record ToolUseContentBlock : ContentBlock
+{
+ [JsonPropertyName("name")]
+ public string? Name { get; init; }
+
+ [JsonPropertyName("id")]
+ public string? Id { get; init; }
+
+ [JsonPropertyName("input")]
+ public JsonElement? Input { get; init; }
+
+ [JsonPropertyName("caller")]
+ public ToolUseCaller? Caller { get; init; }
+}
+
+public record ToolUseCaller
+{
+ [JsonPropertyName("type")]
+ public string? Type { get; init; }
+}
+
+public record ToolResultContentBlock : ContentBlock
+{
+ [JsonPropertyName("tool_use_id")]
+ public string? ToolUseId { get; init; }
+
+ [JsonPropertyName("name")]
+ public string? Name { get; init; }
+
+ [JsonPropertyName("is_error")]
+ public bool? IsError { get; init; }
+
+ [JsonPropertyName("content")]
+ public JsonElement? Content { get; init; }
+}
+
+public record ServerToolUseContentBlock : ContentBlock
+{
+ [JsonPropertyName("name")]
+ public string? Name { get; init; }
+}
+
+public record ServerToolResultContentBlock : ContentBlock
+{
+ [JsonPropertyName("name")]
+ public string? Name { get; init; }
+}
diff --git a/source/Calamari.AiAgent/ClaudeCodeBehaviour/JsonResponseModels/ResultStreamEvent.cs b/source/Calamari.AiAgent/ClaudeCodeBehaviour/JsonResponseModels/ResultStreamEvent.cs
new file mode 100644
index 000000000..699d99ea9
--- /dev/null
+++ b/source/Calamari.AiAgent/ClaudeCodeBehaviour/JsonResponseModels/ResultStreamEvent.cs
@@ -0,0 +1,59 @@
+using System.Collections.Generic;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace Calamari.AiAgent.ClaudeCodeBehaviour.JsonResponseModels;
+
+public record ResultStreamEvent : StreamEvent
+{
+ [JsonPropertyName("subtype")]
+ public string? Subtype { get; init; }
+
+ [JsonPropertyName("is_error")]
+ public bool? IsError { get; init; }
+
+ [JsonPropertyName("result")]
+ public string? Result { get; init; }
+
+ [JsonPropertyName("stop_reason")]
+ public string? StopReason { get; init; }
+
+ [JsonPropertyName("cost_usd")]
+ public double? CostUsd { get; init; }
+
+ [JsonPropertyName("total_cost_usd")]
+ public double? TotalCostUsd { get; init; }
+
+ [JsonPropertyName("duration_ms")]
+ public double? DurationMs { get; init; }
+
+ [JsonPropertyName("duration_api_ms")]
+ public double? DurationApiMs { get; init; }
+
+ [JsonPropertyName("num_turns")]
+ public int? NumTurns { get; init; }
+
+ [JsonPropertyName("usage")]
+ public ResultUsageInfo? Usage { get; init; }
+
+ [JsonPropertyName("modelUsage")]
+ public IReadOnlyDictionary? ModelUsage { get; init; }
+
+ [JsonPropertyName("permission_denials")]
+ public IReadOnlyList? PermissionDenials { get; init; }
+
+ [JsonPropertyName("fast_mode_state")]
+ public string? FastModeState { get; init; }
+}
+
+public record PermissionDenial
+{
+ [JsonPropertyName("tool_name")]
+ public string? ToolName { get; init; }
+
+ [JsonPropertyName("tool_use_id")]
+ public string? ToolUseId { get; init; }
+
+ [JsonPropertyName("tool_input")]
+ public JsonElement? ToolInput { get; init; }
+}
diff --git a/source/Calamari.AiAgent/ClaudeCodeBehaviour/JsonResponseModels/StreamEvent.cs b/source/Calamari.AiAgent/ClaudeCodeBehaviour/JsonResponseModels/StreamEvent.cs
new file mode 100644
index 000000000..c688cabe2
--- /dev/null
+++ b/source/Calamari.AiAgent/ClaudeCodeBehaviour/JsonResponseModels/StreamEvent.cs
@@ -0,0 +1,15 @@
+using System.Text.Json.Serialization;
+
+namespace Calamari.AiAgent.ClaudeCodeBehaviour.JsonResponseModels;
+
+public record StreamEvent
+{
+ [JsonPropertyName("type")]
+ public string? Type { get; init; }
+
+ [JsonPropertyName("session_id")]
+ public string? SessionId { get; init; }
+
+ [JsonPropertyName("uuid")]
+ public string? Uuid { get; init; }
+}
diff --git a/source/Calamari.AiAgent/ClaudeCodeBehaviour/JsonResponseModels/StreamEventType.cs b/source/Calamari.AiAgent/ClaudeCodeBehaviour/JsonResponseModels/StreamEventType.cs
new file mode 100644
index 000000000..b0ce84740
--- /dev/null
+++ b/source/Calamari.AiAgent/ClaudeCodeBehaviour/JsonResponseModels/StreamEventType.cs
@@ -0,0 +1,20 @@
+using System.Runtime.Serialization;
+using System.Text.Json.Serialization;
+
+namespace Calamari.AiAgent.ClaudeCodeBehaviour.JsonResponseModels;
+
+[JsonConverter(typeof(JsonStringEnumConverter))]
+public enum StreamEventType
+{
+ [EnumMember(Value = "system")]
+ System,
+
+ [EnumMember(Value = "assistant")]
+ Assistant,
+
+ [EnumMember(Value = "user")]
+ User,
+
+ [EnumMember(Value = "result")]
+ Result
+}
diff --git a/source/Calamari.AiAgent/ClaudeCodeBehaviour/JsonResponseModels/StreamMessage.cs b/source/Calamari.AiAgent/ClaudeCodeBehaviour/JsonResponseModels/StreamMessage.cs
new file mode 100644
index 000000000..770b9b48b
--- /dev/null
+++ b/source/Calamari.AiAgent/ClaudeCodeBehaviour/JsonResponseModels/StreamMessage.cs
@@ -0,0 +1,28 @@
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace Calamari.AiAgent.ClaudeCodeBehaviour.JsonResponseModels;
+
+public record StreamMessage
+{
+ [JsonPropertyName("model")]
+ public string? Model { get; init; }
+
+ [JsonPropertyName("id")]
+ public string? Id { get; init; }
+
+ [JsonPropertyName("role")]
+ public string? Role { get; init; }
+
+ [JsonPropertyName("stop_reason")]
+ public string? StopReason { get; init; }
+
+ [JsonPropertyName("stop_sequence")]
+ public string? StopSequence { get; init; }
+
+ [JsonPropertyName("usage")]
+ public MessageUsageInfo? Usage { get; init; }
+
+ [JsonPropertyName("content")]
+ public JsonElement[]? Content { get; init; }
+}
diff --git a/source/Calamari.AiAgent/ClaudeCodeBehaviour/JsonResponseModels/SystemStreamEvent.cs b/source/Calamari.AiAgent/ClaudeCodeBehaviour/JsonResponseModels/SystemStreamEvent.cs
new file mode 100644
index 000000000..5d85efc68
--- /dev/null
+++ b/source/Calamari.AiAgent/ClaudeCodeBehaviour/JsonResponseModels/SystemStreamEvent.cs
@@ -0,0 +1,106 @@
+using System.Collections.Generic;
+using System.Text.Json.Serialization;
+
+namespace Calamari.AiAgent.ClaudeCodeBehaviour.JsonResponseModels;
+
+public record SystemStreamEvent : StreamEvent
+{
+ [JsonPropertyName("subtype")]
+ public string? Subtype { get; init; }
+
+ [JsonPropertyName("attempt")]
+ public int? Attempt { get; init; }
+
+ [JsonPropertyName("retry_delay_ms")]
+ public int? RetryDelayMs { get; init; }
+
+ [JsonPropertyName("error")]
+ public string? Error { get; init; }
+
+ [JsonPropertyName("error_status")]
+ public int? ErrorStatus { get; init; }
+
+ [JsonPropertyName("hook_id")]
+ public string? HookId { get; init; }
+
+ [JsonPropertyName("hook_name")]
+ public string? HookName { get; init; }
+
+ [JsonPropertyName("hook_event")]
+ public string? HookEvent { get; init; }
+
+ [JsonPropertyName("output")]
+ public string? Output { get; init; }
+
+ [JsonPropertyName("stdout")]
+ public string? Stdout { get; init; }
+
+ [JsonPropertyName("stderr")]
+ public string? Stderr { get; init; }
+
+ [JsonPropertyName("exit_code")]
+ public int? ExitCode { get; init; }
+
+ [JsonPropertyName("outcome")]
+ public string? Outcome { get; init; }
+
+ [JsonPropertyName("cwd")]
+ public string? Cwd { get; init; }
+
+ [JsonPropertyName("tools")]
+ public IReadOnlyList? Tools { get; init; }
+
+ [JsonPropertyName("mcp_servers")]
+ public IReadOnlyList? McpServers { get; init; }
+
+ [JsonPropertyName("model")]
+ public string? Model { get; init; }
+
+ [JsonPropertyName("permissionMode")]
+ public string? PermissionMode { get; init; }
+
+ [JsonPropertyName("slash_commands")]
+ public IReadOnlyList? SlashCommands { get; init; }
+
+ [JsonPropertyName("apiKeySource")]
+ public string? ApiKeySource { get; init; }
+
+ [JsonPropertyName("claude_code_version")]
+ public string? ClaudeCodeVersion { get; init; }
+
+ [JsonPropertyName("output_style")]
+ public string? OutputStyle { get; init; }
+
+ [JsonPropertyName("agents")]
+ public IReadOnlyList? Agents { get; init; }
+
+ [JsonPropertyName("skills")]
+ public IReadOnlyList? Skills { get; init; }
+
+ [JsonPropertyName("plugins")]
+ public IReadOnlyList? Plugins { get; init; }
+
+ [JsonPropertyName("fast_mode_state")]
+ public string? FastModeState { get; init; }
+}
+
+public record McpServerStatus
+{
+ [JsonPropertyName("name")]
+ public string? Name { get; init; }
+
+ [JsonPropertyName("status")]
+ public string? Status { get; init; }
+}
+
+public record PluginInfo
+{
+ [JsonPropertyName("name")]
+ public string? Name { get; init; }
+
+ [JsonPropertyName("path")]
+ public string? Path { get; init; }
+
+ [JsonPropertyName("source")]
+ public string? Source { get; init; }
+}
diff --git a/source/Calamari.AiAgent/ClaudeCodeBehaviour/JsonResponseModels/UsageInfo.cs b/source/Calamari.AiAgent/ClaudeCodeBehaviour/JsonResponseModels/UsageInfo.cs
new file mode 100644
index 000000000..17c879035
--- /dev/null
+++ b/source/Calamari.AiAgent/ClaudeCodeBehaviour/JsonResponseModels/UsageInfo.cs
@@ -0,0 +1,102 @@
+using System.Text.Json.Serialization;
+
+namespace Calamari.AiAgent.ClaudeCodeBehaviour.JsonResponseModels;
+
+public record MessageUsageInfo
+{
+ [JsonPropertyName("input_tokens")]
+ public int? InputTokens { get; init; }
+
+ [JsonPropertyName("output_tokens")]
+ public int? OutputTokens { get; init; }
+
+ [JsonPropertyName("cache_read_input_tokens")]
+ public int? CacheReadInputTokens { get; init; }
+
+ [JsonPropertyName("cache_creation_input_tokens")]
+ public int? CacheCreationInputTokens { get; init; }
+
+ [JsonPropertyName("cache_creation")]
+ public CacheCreationInfo? CacheCreation { get; init; }
+
+ [JsonPropertyName("service_tier")]
+ public string? ServiceTier { get; init; }
+
+ [JsonPropertyName("inference_geo")]
+ public string? InferenceGeo { get; init; }
+}
+
+public record ResultUsageInfo
+{
+ [JsonPropertyName("input_tokens")]
+ public int? InputTokens { get; init; }
+
+ [JsonPropertyName("output_tokens")]
+ public int? OutputTokens { get; init; }
+
+ [JsonPropertyName("cache_read_input_tokens")]
+ public int? CacheReadInputTokens { get; init; }
+
+ [JsonPropertyName("cache_creation_input_tokens")]
+ public int? CacheCreationInputTokens { get; init; }
+
+ [JsonPropertyName("server_tool_use")]
+ public ServerToolUseUsage? ServerToolUse { get; init; }
+
+ [JsonPropertyName("service_tier")]
+ public string? ServiceTier { get; init; }
+
+ [JsonPropertyName("cache_creation")]
+ public CacheCreationInfo? CacheCreation { get; init; }
+
+ [JsonPropertyName("inference_geo")]
+ public string? InferenceGeo { get; init; }
+
+ [JsonPropertyName("speed")]
+ public string? Speed { get; init; }
+}
+
+public record ModelUsageInfo
+{
+ [JsonPropertyName("inputTokens")]
+ public int? InputTokens { get; init; }
+
+ [JsonPropertyName("outputTokens")]
+ public int? OutputTokens { get; init; }
+
+ [JsonPropertyName("cacheReadInputTokens")]
+ public int? CacheReadInputTokens { get; init; }
+
+ [JsonPropertyName("cacheCreationInputTokens")]
+ public int? CacheCreationInputTokens { get; init; }
+
+ [JsonPropertyName("webSearchRequests")]
+ public int? WebSearchRequests { get; init; }
+
+ [JsonPropertyName("costUSD")]
+ public double? CostUsd { get; init; }
+
+ [JsonPropertyName("contextWindow")]
+ public int? ContextWindow { get; init; }
+
+ [JsonPropertyName("maxOutputTokens")]
+ public int? MaxOutputTokens { get; init; }
+}
+
+public record ServerToolUseUsage
+{
+ [JsonPropertyName("web_search_requests")]
+ public int? WebSearchRequests { get; init; }
+
+ [JsonPropertyName("web_fetch_requests")]
+ public int? WebFetchRequests { get; init; }
+}
+
+public record CacheCreationInfo
+{
+ [JsonPropertyName("ephemeral_5m_input_tokens")]
+ public int? Ephemeral5mInputTokens { get; init; }
+
+ [JsonPropertyName("ephemeral_1h_input_tokens")]
+ public int? Ephemeral1hInputTokens { get; init; }
+}
diff --git a/source/Calamari.AiAgent/ClaudeCodeBehaviour/JsonResponseModels/UserStreamEvent.cs b/source/Calamari.AiAgent/ClaudeCodeBehaviour/JsonResponseModels/UserStreamEvent.cs
new file mode 100644
index 000000000..9bec884b8
--- /dev/null
+++ b/source/Calamari.AiAgent/ClaudeCodeBehaviour/JsonResponseModels/UserStreamEvent.cs
@@ -0,0 +1,18 @@
+using System.Text.Json.Serialization;
+
+namespace Calamari.AiAgent.ClaudeCodeBehaviour.JsonResponseModels;
+
+public record UserStreamEvent : StreamEvent
+{
+ [JsonPropertyName("message")]
+ public StreamMessage? Message { get; init; }
+
+ [JsonPropertyName("parent_tool_use_id")]
+ public string? ParentToolUseId { get; init; }
+
+ [JsonPropertyName("timestamp")]
+ public string? Timestamp { get; init; }
+
+ [JsonPropertyName("isSynthetic")]
+ public bool? IsSynthetic { get; init; }
+}
diff --git a/source/Calamari.AiAgent/ClaudeCodeBehaviour/McpWriter.cs b/source/Calamari.AiAgent/ClaudeCodeBehaviour/McpWriter.cs
new file mode 100644
index 000000000..6976b8b50
--- /dev/null
+++ b/source/Calamari.AiAgent/ClaudeCodeBehaviour/McpWriter.cs
@@ -0,0 +1,131 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Text.Json;
+using Calamari.Common.Commands;
+using Calamari.Common.Plumbing.Logging;
+using Calamari.Common.Plumbing.Variables;
+
+namespace Calamari.AiAgent.ClaudeCodeBehaviour;
+
+public class McpWriter(IVariables variables)
+{
+ static readonly string ConfigName = "mcp-config.json";
+
+ public string SetupMcpConfig(string workingDir)
+ {
+ var mcpServers = BuildMcpServers();
+ var config = new { mcpServers };
+ var json = JsonSerializer.Serialize(config, new JsonSerializerOptions { WriteIndented = true });
+ var path = Path.Combine(workingDir, ConfigName);
+ File.WriteAllText(path, json);
+ return path;
+ }
+
+ public IEnumerable GetAllowedTools()
+ {
+ var mcpServers = GetCustomMcpServers();
+
+ // TODO: Use explicitly allowed MCP tools
+ return mcpServers.Select(serverName => $"mcp__{serverName.Name}__*");
+ }
+
+ Dictionary BuildMcpServers()
+ {
+ var path = Environment.GetEnvironmentVariable("PATH") ?? "";
+
+ var servers = AddCustomMcpServer(path);
+ AddOctopusMcp(servers, path);
+ return servers;
+ }
+
+ void AddOctopusMcp(Dictionary servers, string path)
+ {
+ var octopusToken = variables.Get(SpecialVariables.Action.Claude.OctopusToken);
+ if (string.IsNullOrWhiteSpace(octopusToken))
+ return;
+
+
+ // Octopus MCP server is always added when a token is available
+ var octopusServerUrl = variables.Get(SpecialVariables.Web.ServerUri);
+ if (string.IsNullOrWhiteSpace(octopusServerUrl))
+ {
+ Log.Warn("Unable to find Octopus Server URL");
+ }
+ else
+ {
+ Log.Verbose("Octopus Server URL: " + octopusServerUrl);
+ servers["octopus"] = new McpServerConfig
+ {
+ Command = "npx",
+ Args = new[] { "-y", "@octopusdeploy/mcp-server" },
+ Env = new Dictionary
+ {
+ ["OCTOPUS_SERVER_URL"] = octopusServerUrl,
+ ["OCTOPUS_API_KEY"] = octopusToken,
+ ["PATH"] = path,
+ },
+ };
+ }
+ }
+
+ List GetCustomMcpServers()
+ {
+ var mcpServersJson = variables.Get(SpecialVariables.Action.Claude.McpServers);
+ if (string.IsNullOrWhiteSpace(mcpServersJson))
+ {
+ return new List();
+ }
+
+ try
+ {
+ var customServers = JsonSerializer.Deserialize>(mcpServersJson, new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
+
+ return customServers ?? new List();
+ }
+ catch (JsonException ex)
+ {
+ throw new CommandException($"Failed to parse MCP servers configuration: {ex.Message}");
+ }
+ }
+
+ Dictionary AddCustomMcpServer(string path)
+ {
+ var entries = GetCustomMcpServers();
+
+ var mcpServerConfigs = new Dictionary();
+ if (entries.Any())
+ {
+ foreach (var entry in entries)
+ {
+ if (string.IsNullOrWhiteSpace(entry.Name))
+ throw new CommandException("Each MCP server must have a name.");
+ if (string.IsNullOrWhiteSpace(entry.Command))
+ throw new CommandException($"MCP server '{entry.Name}' must have a command.");
+
+ var env = entry.Env != null
+ ? new Dictionary(entry.Env)
+ : new Dictionary();
+
+ if (!env.ContainsKey("PATH"))
+ env["PATH"] = path;
+
+ mcpServerConfigs[entry.Name] = new McpServerConfig
+ {
+ Type = entry.Type ?? "stdio",
+ Command = entry.Command,
+ Args = entry.Args,
+ Env = env,
+ };
+ Log.Verbose($"MCP server '{entry.Name}' added.");
+ }
+ }
+
+ return mcpServerConfigs;
+ }
+
+
+
+
+}
\ No newline at end of file
diff --git a/source/Calamari.AiAgent/ClaudeCodeBehaviour/SkillsWriter.cs b/source/Calamari.AiAgent/ClaudeCodeBehaviour/SkillsWriter.cs
new file mode 100644
index 000000000..78212778c
--- /dev/null
+++ b/source/Calamari.AiAgent/ClaudeCodeBehaviour/SkillsWriter.cs
@@ -0,0 +1,114 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Reflection;
+using System.Text;
+using Calamari.Common.Commands;
+using Calamari.Common.Plumbing.Variables;
+
+namespace Calamari.AiAgent.ClaudeCodeBehaviour;
+
+public class SkillsWriter(IVariables variables)
+{
+ const string SkillsResourcePrefix = "Calamari.AiAgent.ClaudeCodeBehaviour.DefaultContext.Skills.";
+
+ public void SetupSkills(string workingDir)
+ {
+ var skillsDir = Path.Combine(workingDir, ".claude", "skills");
+ Directory.CreateDirectory(skillsDir);
+
+ CreateSystemSkillFiles(skillsDir);
+ CreateUserSkillFiles(skillsDir);
+ }
+
+ static void CreateSystemSkillFiles(string skillsDir)
+ {
+ var assembly = Assembly.GetExecutingAssembly();
+ foreach (var resourceName in assembly.GetManifestResourceNames())
+ {
+ if (!resourceName.StartsWith(SkillsResourcePrefix, StringComparison.Ordinal))
+ continue;
+
+ var fileName = resourceName.Substring(SkillsResourcePrefix.Length);
+ var skillName = Path.GetFileNameWithoutExtension(fileName);
+ var innerSkillDir = Path.Combine(skillsDir, skillName);
+
+ using var stream = assembly.GetManifestResourceStream(resourceName)!;
+ using var reader = new StreamReader(stream);
+
+ Directory.CreateDirectory(innerSkillDir);
+ File.WriteAllText(Path.Combine(innerSkillDir, "SKILL.md"), reader.ReadToEnd());
+ }
+ }
+
+ void CreateUserSkillFiles(string skillsDir)
+ {
+ var userSkills = BuildUserSkills();
+
+ foreach (var skill in userSkills)
+ {
+ var dirName = SanitizeFileName(skill.Name);
+ var innerSkillDir = Path.GetFullPath(Path.Combine(skillsDir, dirName));
+
+ if (!innerSkillDir.StartsWith(Path.GetFullPath(skillsDir) + Path.DirectorySeparatorChar, StringComparison.Ordinal))
+ throw new CommandException($"Skill name '{skill.Name}' results in a path outside the skills directory.");
+
+ Directory.CreateDirectory(innerSkillDir);
+ File.WriteAllText(Path.Combine(innerSkillDir, "SKILL.md"), skill.Content);
+ }
+ }
+
+ List BuildUserSkills()
+ {
+ var skills = new List();
+ var indexes = variables.GetIndexes(SpecialVariables.Action.Claude.Skills);
+ foreach (var index in indexes)
+ {
+ var prefix = $"{SpecialVariables.Action.Claude.Skills}[{index}].";
+ var name = variables.Get(prefix + SpecialVariables.Action.Claude.SkillName);
+ var content = variables.Get(prefix + SpecialVariables.Action.Claude.SkillContent);
+
+ if (!string.IsNullOrWhiteSpace(name) && !string.IsNullOrWhiteSpace(content))
+ skills.Add(new UserSkill { Name = name, Content = content });
+ }
+ return skills;
+ }
+
+ static readonly HashSet WindowsReservedNames = new(StringComparer.OrdinalIgnoreCase)
+ {
+ "CON", "PRN", "AUX", "NUL",
+ "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9",
+ "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
+ };
+
+ internal static string SanitizeFileName(string name)
+ {
+ if (string.IsNullOrWhiteSpace(name))
+ throw new CommandException("Skill name cannot be empty.");
+
+ var invalid = Path.GetInvalidFileNameChars();
+ var sanitized = new StringBuilder(name.Length);
+ foreach (var c in name)
+ {
+ if (Array.IndexOf(invalid, c) >= 0 || char.IsControl(c) || c is '<' or '>' or ':' or '"' or '|' or '?' or '*' or '\\')
+ sanitized.Append('-');
+ else
+ sanitized.Append(c);
+ }
+
+ // Strip leading dots to prevent hidden files / relative path tricks
+ var result = sanitized.ToString().TrimStart('.');
+
+ if (string.IsNullOrWhiteSpace(result))
+ throw new CommandException($"Skill name '{name}' is not a valid file name.");
+
+ if (WindowsReservedNames.Contains(result))
+ throw new CommandException($"Skill name '{name}' is a reserved file name.");
+
+ // Filesystem limits are typically 255 bytes; truncate to be safe
+ if (result.Length > 200)
+ result = result.Substring(0, 200);
+
+ return result;
+ }
+}
\ No newline at end of file
diff --git a/source/Calamari.AiAgent/ClaudeCodeBehaviour/SystemPromptWriter.cs b/source/Calamari.AiAgent/ClaudeCodeBehaviour/SystemPromptWriter.cs
new file mode 100644
index 000000000..abd526ca2
--- /dev/null
+++ b/source/Calamari.AiAgent/ClaudeCodeBehaviour/SystemPromptWriter.cs
@@ -0,0 +1,26 @@
+using System;
+using System.IO;
+using System.Reflection;
+
+namespace Calamari.AiAgent.ClaudeCodeBehaviour;
+
+public class SystemPromptWriter
+{
+ public string WriteSystemPromptFile(string workingDir)
+ {
+ var res = $"{GetType().Namespace}.DefaultContext.system-prompt.md";
+ var assembly = Assembly.GetExecutingAssembly();
+ var path = Path.Combine(workingDir, "system-prompt.md");
+
+ using var stream = assembly.GetManifestResourceStream(res);
+ if (stream == null)
+ {
+ throw new Exception($"Could not find expected system prompt embedded resource.");
+ }
+
+ using var reader = new StreamReader(stream);
+ File.WriteAllText(path, reader.ReadToEnd());
+
+ return path;
+ }
+}
\ No newline at end of file
diff --git a/source/Calamari.AiAgent/Program.cs b/source/Calamari.AiAgent/Program.cs
new file mode 100644
index 000000000..0838d2870
--- /dev/null
+++ b/source/Calamari.AiAgent/Program.cs
@@ -0,0 +1,18 @@
+using System.Threading.Tasks;
+using Calamari.Common;
+using Calamari.Common.Plumbing.Logging;
+
+namespace Calamari.AiAgent
+{
+ public class Program : CalamariFlavourProgramAsync
+ {
+ public Program(ILog log) : base(log)
+ {
+ }
+
+ public static Task Main(string[] args)
+ {
+ return new Program(ConsoleLog.Instance).Run(args);
+ }
+ }
+}
diff --git a/source/Calamari.AiAgent/RunAgentCommand.cs b/source/Calamari.AiAgent/RunAgentCommand.cs
new file mode 100644
index 000000000..6fd47ec7d
--- /dev/null
+++ b/source/Calamari.AiAgent/RunAgentCommand.cs
@@ -0,0 +1,16 @@
+using System.Collections.Generic;
+using Calamari.AiAgent.ClaudeCodeBehaviour;
+using Calamari.Common.Commands;
+using Calamari.Common.Plumbing.Pipeline;
+
+namespace Calamari.AiAgent
+{
+ [Command("run-claude-code", Description = "Invokes an Claude Code CLI")]
+ public class RunAgentCommand : PipelineCommand
+ {
+ protected override IEnumerable Deploy(DeployResolver resolver)
+ {
+ yield return resolver.Create();
+ }
+ }
+}
diff --git a/source/Calamari.AiAgent/SpecialVariables.cs b/source/Calamari.AiAgent/SpecialVariables.cs
new file mode 100644
index 000000000..185c82bad
--- /dev/null
+++ b/source/Calamari.AiAgent/SpecialVariables.cs
@@ -0,0 +1,34 @@
+namespace Calamari.AiAgent
+{
+ public static class SpecialVariables
+ {
+ public static class Web
+ {
+
+ public const string ServerUri = "Octopus.Web.ServerUri";
+ }
+
+ public static class Action
+ {
+ public static class Claude
+ {
+ public const string Prompt = "Octopus.Action.Claude.Prompt";
+ public const string ApiToken = "Octopus.Action.Claude.ApiToken";
+ public const string Model = "Octopus.Action.Claude.Model";
+ public const string Response = "Octopus.Action.Claude.Response";
+ public const string McpServers = "Octopus.Action.Claude.McpServers";
+ public const string MaxTurns = "Octopus.Action.Claude.MaxTurns";
+ public const string MaxBudgetUsd = "Octopus.Action.Claude.MaxBudgetUsd";
+ public const string OctopusToken = "Octopus.Action.Claude.OctopusToken";
+ public const string AllowedTools = "Octopus.Action.Claude.AllowedTools";
+ public const string Effort = "Octopus.Action.Claude.Effort";
+ public const string RunAsUsername = "Octopus.Action.Claude.RunAsUsername";
+ public const string RunAsPassword = "Octopus.Action.Claude.RunAsPassword";
+
+ public const string Skills = "Octopus.Action.Claude.Skills";
+ public const string SkillName = "Name";
+ public const string SkillContent = "Content";
+ }
+ }
+ }
+}
diff --git a/source/Calamari.ConsolidateCalamariPackages/BuildableCalamariProjects.cs b/source/Calamari.ConsolidateCalamariPackages/BuildableCalamariProjects.cs
index 81514fa5d..049282a13 100644
--- a/source/Calamari.ConsolidateCalamariPackages/BuildableCalamariProjects.cs
+++ b/source/Calamari.ConsolidateCalamariPackages/BuildableCalamariProjects.cs
@@ -14,6 +14,7 @@ public static string[] GetCalamariProjectsToBuild(bool isWindows)
static readonly string[] NonWindows =
[
"Calamari",
+ "Calamari.AiAgent",
"Calamari.AzureAppService",
"Calamari.AzureResourceGroup",
"Calamari.GoogleCloudScripting",
diff --git a/source/Calamari.Contracts/ClaudeCode/ServiceMessages.cs b/source/Calamari.Contracts/ClaudeCode/ServiceMessages.cs
new file mode 100644
index 000000000..51cd1bc15
--- /dev/null
+++ b/source/Calamari.Contracts/ClaudeCode/ServiceMessages.cs
@@ -0,0 +1,22 @@
+using System;
+
+namespace Octopus.Calamari.Contracts.ClaudeCode;
+
+public static class ClaudeCodeServiceMessages
+{
+ public static class Usage
+ {
+ public const string Name = "claude-code-usage";
+
+ public const string CostUsdAttribute = "costUsd";
+ public const string TotalCostUsdAttribute = "totalCostUsd";
+ public const string DurationMsAttribute = "durationMs";
+ public const string DurationApiMsAttribute = "durationApiMs";
+ public const string NumTurnsAttribute = "numTurns";
+ public const string InputTokensAttribute = "inputTokens";
+ public const string OutputTokensAttribute = "outputTokens";
+ public const string CacheReadInputTokensAttribute = "cacheReadInputTokens";
+ public const string CacheCreationInputTokensAttribute = "cacheCreationInputTokens";
+ public const string ModelAttribute = "model"; //TODO: @team-modern-deployments ensure we capture the model used
+ }
+}
\ No newline at end of file
diff --git a/source/Calamari.sln b/source/Calamari.sln
index c776abd64..7272d52af 100644
--- a/source/Calamari.sln
+++ b/source/Calamari.sln
@@ -87,6 +87,9 @@ EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Calamari.Contracts", "Calamari.Contracts\Calamari.Contracts.csproj", "{13583496-C3D2-4ADE-9087-65583326C469}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Calamari.DockerCredentialHelper", "Calamari.DockerCredentialHelper\Calamari.DockerCredentialHelper.csproj", "{B34DBEEC-7AC2-4BFE-ACDD-1788828925BD}"
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Calamari.AiAgent", "Calamari.AiAgent\Calamari.AiAgent.csproj", "{767EB703-FF66-4955-9AE2-322A93FB69EE}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Calamari.AiAgent.Tests", "Calamari.AiAgent.Tests\Calamari.AiAgent.Tests.csproj", "{8D3FCBF5-369E-44B3-BD72-4C11E8058027}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
@@ -232,6 +235,14 @@ Global
{B34DBEEC-7AC2-4BFE-ACDD-1788828925BD}.Debug|Any CPU.Build.0 = Debug|Any CPU
{B34DBEEC-7AC2-4BFE-ACDD-1788828925BD}.Release|Any CPU.ActiveCfg = Release|Any CPU
{B34DBEEC-7AC2-4BFE-ACDD-1788828925BD}.Release|Any CPU.Build.0 = Release|Any CPU
+ {767EB703-FF66-4955-9AE2-322A93FB69EE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {767EB703-FF66-4955-9AE2-322A93FB69EE}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {767EB703-FF66-4955-9AE2-322A93FB69EE}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {767EB703-FF66-4955-9AE2-322A93FB69EE}.Release|Any CPU.Build.0 = Release|Any CPU
+ {8D3FCBF5-369E-44B3-BD72-4C11E8058027}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {8D3FCBF5-369E-44B3-BD72-4C11E8058027}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {8D3FCBF5-369E-44B3-BD72-4C11E8058027}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {8D3FCBF5-369E-44B3-BD72-4C11E8058027}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE