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