diff --git a/.github/workflows/codegen-check.yml b/.github/workflows/codegen-check.yml new file mode 100644 index 00000000..c7d29522 --- /dev/null +++ b/.github/workflows/codegen-check.yml @@ -0,0 +1,56 @@ +name: "Codegen Check" + +on: + push: + branches: + - main + pull_request: + paths: + - 'scripts/codegen/**' + - 'nodejs/src/generated/**' + - 'dotnet/src/Generated/**' + - 'python/copilot/generated/**' + - 'go/generated_*.go' + - 'go/rpc/**' + - '.github/workflows/codegen-check.yml' + workflow_dispatch: + +permissions: + contents: read + +jobs: + check: + name: "Verify generated files are up-to-date" + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + + - uses: actions/setup-go@v5 + with: + go-version: '1.22' + + - name: Install nodejs SDK dependencies + working-directory: ./nodejs + run: npm ci + + - name: Install codegen dependencies + working-directory: ./scripts/codegen + run: npm ci + + - name: Run codegen + working-directory: ./scripts/codegen + run: npm run generate + + - name: Check for uncommitted changes + run: | + if [ -n "$(git status --porcelain)" ]; then + echo "::error::Generated files are out of date. Run 'cd scripts/codegen && npm run generate' and commit the changes." + git diff --stat + git diff + exit 1 + fi + echo "✅ Generated files are up-to-date" diff --git a/docs/guides/setup/backend-services.md b/docs/guides/setup/backend-services.md index 7581cff3..c9bc13f8 100644 --- a/docs/guides/setup/backend-services.md +++ b/docs/guides/setup/backend-services.md @@ -131,9 +131,10 @@ response = await session.send_and_wait({"prompt": message})
Go + ```go client := copilot.NewClient(&copilot.ClientOptions{ - CLIUrl: "localhost:4321", + CLIUrl:"localhost:4321", }) client.Start(ctx) defer client.Stop() @@ -151,6 +152,7 @@ response, _ := session.SendAndWait(ctx, copilot.MessageOptions{Prompt: message})
.NET + ```csharp var client = new CopilotClient(new CopilotClientOptions { diff --git a/docs/guides/setup/bundled-cli.md b/docs/guides/setup/bundled-cli.md index 9fc88f09..6daf57b5 100644 --- a/docs/guides/setup/bundled-cli.md +++ b/docs/guides/setup/bundled-cli.md @@ -105,9 +105,10 @@ await client.stop()
Go + ```go client := copilot.NewClient(&copilot.ClientOptions{ - CLIPath: "./vendor/copilot", + CLIPath:"./vendor/copilot", }) if err := client.Start(ctx); err != nil { log.Fatal(err) diff --git a/docs/guides/setup/byok.md b/docs/guides/setup/byok.md index 3a6ce596..24e22b21 100644 --- a/docs/guides/setup/byok.md +++ b/docs/guides/setup/byok.md @@ -118,6 +118,7 @@ await client.stop()
Go + ```go client := copilot.NewClient(nil) client.Start(ctx) diff --git a/docs/guides/setup/github-oauth.md b/docs/guides/setup/github-oauth.md index a7aac473..07251c8f 100644 --- a/docs/guides/setup/github-oauth.md +++ b/docs/guides/setup/github-oauth.md @@ -170,6 +170,7 @@ response = await session.send_and_wait({"prompt": "Hello!"})
Go + ```go func createClientForUser(userToken string) *copilot.Client { return copilot.NewClient(&copilot.ClientOptions{ @@ -195,6 +196,7 @@ response, _ := session.SendAndWait(ctx, copilot.MessageOptions{Prompt: "Hello!"}
.NET + ```csharp CopilotClient CreateClientForUser(string userToken) => new CopilotClient(new CopilotClientOptions diff --git a/docs/guides/setup/local-cli.md b/docs/guides/setup/local-cli.md index 8d9573eb..a5fa906b 100644 --- a/docs/guides/setup/local-cli.md +++ b/docs/guides/setup/local-cli.md @@ -68,6 +68,7 @@ await client.stop()
Go + ```go client := copilot.NewClient(nil) if err := client.Start(ctx); err != nil { diff --git a/dotnet/src/Client.cs b/dotnet/src/Client.cs index 74f1c66f..21055db9 100644 --- a/dotnet/src/Client.cs +++ b/dotnet/src/Client.cs @@ -14,6 +14,7 @@ using System.Text.Json; using System.Text.Json.Serialization; using System.Text.RegularExpressions; +using GitHub.Copilot.SDK.Rpc; namespace GitHub.Copilot.SDK; @@ -63,6 +64,19 @@ public partial class CopilotClient : IDisposable, IAsyncDisposable private readonly List> _lifecycleHandlers = new(); private readonly Dictionary>> _typedLifecycleHandlers = new(); private readonly object _lifecycleHandlersLock = new(); + private ServerRpc? _rpc; + + /// + /// Gets the typed RPC client for server-scoped methods (no session required). + /// + /// + /// The client must be started before accessing this property. Use or set to true. + /// + /// Thrown if the client has been disposed. + /// Thrown if the client is not started. + public ServerRpc Rpc => _disposed + ? throw new ObjectDisposedException(nameof(CopilotClient)) + : _rpc ?? throw new InvalidOperationException("Client is not started. Call StartAsync first."); /// /// Creates a new instance of . @@ -289,7 +303,8 @@ private async Task CleanupConnectionAsync(List? errors) try { ctx.Rpc.Dispose(); } catch (Exception ex) { errors?.Add(ex); } - // Clear models cache + // Clear RPC and models cache + _rpc = null; _modelsCache = null; if (ctx.NetworkStream is not null) @@ -1040,6 +1055,9 @@ private async Task ConnectToServerAsync(Process? cliProcess, string? rpc.AddLocalRpcMethod("userInput.request", handler.OnUserInputRequest); rpc.AddLocalRpcMethod("hooks.invoke", handler.OnHooksInvoke); rpc.StartListening(); + + _rpc = new ServerRpc(rpc); + return new Connection(rpc, cliProcess, tcpClient, networkStream); } @@ -1062,6 +1080,7 @@ private static JsonSerializerOptions CreateSerializerOptions() options.TypeInfoResolverChain.Add(TypesJsonContext.Default); options.TypeInfoResolverChain.Add(CopilotSession.SessionJsonContext.Default); options.TypeInfoResolverChain.Add(SessionEventsJsonContext.Default); + options.TypeInfoResolverChain.Add(SDK.Rpc.RpcJsonContext.Default); options.MakeReadOnly(); diff --git a/dotnet/src/Generated/Rpc.cs b/dotnet/src/Generated/Rpc.cs new file mode 100644 index 00000000..c88ae790 --- /dev/null +++ b/dotnet/src/Generated/Rpc.cs @@ -0,0 +1,365 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +// AUTO-GENERATED FILE - DO NOT EDIT +// Generated from: api.schema.json + +using System.Text.Json; +using System.Text.Json.Serialization; +using StreamJsonRpc; + +namespace GitHub.Copilot.SDK.Rpc; + +public class PingResult +{ + /// Echoed message (or default greeting) + [JsonPropertyName("message")] + public string Message { get; set; } = string.Empty; + + /// Server timestamp in milliseconds + [JsonPropertyName("timestamp")] + public double Timestamp { get; set; } + + /// Server protocol version number + [JsonPropertyName("protocolVersion")] + public double ProtocolVersion { get; set; } +} + +internal class PingRequest +{ + [JsonPropertyName("message")] + public string? Message { get; set; } +} + +public class ModelCapabilitiesSupports +{ + [JsonPropertyName("vision")] + public bool Vision { get; set; } + + /// Whether this model supports reasoning effort configuration + [JsonPropertyName("reasoningEffort")] + public bool ReasoningEffort { get; set; } +} + +public class ModelCapabilitiesLimits +{ + [JsonPropertyName("max_prompt_tokens")] + public double? MaxPromptTokens { get; set; } + + [JsonPropertyName("max_output_tokens")] + public double? MaxOutputTokens { get; set; } + + [JsonPropertyName("max_context_window_tokens")] + public double MaxContextWindowTokens { get; set; } +} + +/// Model capabilities and limits +public class ModelCapabilities +{ + [JsonPropertyName("supports")] + public ModelCapabilitiesSupports Supports { get; set; } = new(); + + [JsonPropertyName("limits")] + public ModelCapabilitiesLimits Limits { get; set; } = new(); +} + +/// Policy state (if applicable) +public class ModelPolicy +{ + [JsonPropertyName("state")] + public string State { get; set; } = string.Empty; + + [JsonPropertyName("terms")] + public string Terms { get; set; } = string.Empty; +} + +/// Billing information +public class ModelBilling +{ + [JsonPropertyName("multiplier")] + public double Multiplier { get; set; } +} + +public class Model +{ + /// Model identifier (e.g., "claude-sonnet-4.5") + [JsonPropertyName("id")] + public string Id { get; set; } = string.Empty; + + /// Display name + [JsonPropertyName("name")] + public string Name { get; set; } = string.Empty; + + /// Model capabilities and limits + [JsonPropertyName("capabilities")] + public ModelCapabilities Capabilities { get; set; } = new(); + + /// Policy state (if applicable) + [JsonPropertyName("policy")] + public ModelPolicy? Policy { get; set; } + + /// Billing information + [JsonPropertyName("billing")] + public ModelBilling? Billing { get; set; } + + /// Supported reasoning effort levels (only present if model supports reasoning effort) + [JsonPropertyName("supportedReasoningEfforts")] + public List? SupportedReasoningEfforts { get; set; } + + /// Default reasoning effort level (only present if model supports reasoning effort) + [JsonPropertyName("defaultReasoningEffort")] + public string? DefaultReasoningEffort { get; set; } +} + +public class ModelsListResult +{ + /// List of available models with full metadata + [JsonPropertyName("models")] + public List Models { get; set; } = new(); +} + +public class Tool +{ + /// Tool identifier (e.g., "bash", "grep", "str_replace_editor") + [JsonPropertyName("name")] + public string Name { get; set; } = string.Empty; + + /// Optional namespaced name for declarative filtering (e.g., "playwright/navigate" for MCP tools) + [JsonPropertyName("namespacedName")] + public string? NamespacedName { get; set; } + + /// Description of what the tool does + [JsonPropertyName("description")] + public string Description { get; set; } = string.Empty; + + /// JSON Schema for the tool's input parameters + [JsonPropertyName("parameters")] + public Dictionary? Parameters { get; set; } + + /// Optional instructions for how to use this tool effectively + [JsonPropertyName("instructions")] + public string? Instructions { get; set; } +} + +public class ToolsListResult +{ + /// List of available built-in tools with metadata + [JsonPropertyName("tools")] + public List Tools { get; set; } = new(); +} + +internal class ListRequest +{ + [JsonPropertyName("model")] + public string? Model { get; set; } +} + +public class AccountGetQuotaResultQuotaSnapshotsValue +{ + /// Number of requests included in the entitlement + [JsonPropertyName("entitlementRequests")] + public double EntitlementRequests { get; set; } + + /// Number of requests used so far this period + [JsonPropertyName("usedRequests")] + public double UsedRequests { get; set; } + + /// Percentage of entitlement remaining + [JsonPropertyName("remainingPercentage")] + public double RemainingPercentage { get; set; } + + /// Number of overage requests made this period + [JsonPropertyName("overage")] + public double Overage { get; set; } + + /// Whether pay-per-request usage is allowed when quota is exhausted + [JsonPropertyName("overageAllowedWithExhaustedQuota")] + public bool OverageAllowedWithExhaustedQuota { get; set; } + + /// Date when the quota resets (ISO 8601) + [JsonPropertyName("resetDate")] + public string? ResetDate { get; set; } +} + +public class AccountGetQuotaResult +{ + /// Quota snapshots keyed by type (e.g., chat, completions, premium_interactions) + [JsonPropertyName("quotaSnapshots")] + public Dictionary QuotaSnapshots { get; set; } = new(); +} + +public class SessionModelGetCurrentResult +{ + [JsonPropertyName("modelId")] + public string? ModelId { get; set; } +} + +internal class GetCurrentRequest +{ + [JsonPropertyName("sessionId")] + public string SessionId { get; set; } = string.Empty; +} + +public class SessionModelSwitchToResult +{ + [JsonPropertyName("modelId")] + public string? ModelId { get; set; } +} + +internal class SwitchToRequest +{ + [JsonPropertyName("sessionId")] + public string SessionId { get; set; } = string.Empty; + + [JsonPropertyName("modelId")] + public string ModelId { get; set; } = string.Empty; +} + +/// Typed server-scoped RPC methods (no session required). +public class ServerRpc +{ + private readonly JsonRpc _rpc; + + internal ServerRpc(JsonRpc rpc) + { + _rpc = rpc; + Models = new ModelsApi(rpc); + Tools = new ToolsApi(rpc); + Account = new AccountApi(rpc); + } + + /// Calls "ping". + public async Task PingAsync(string? message = null, CancellationToken cancellationToken = default) + { + var request = new PingRequest { Message = message }; + return await CopilotClient.InvokeRpcAsync(_rpc, "ping", [request], cancellationToken); + } + + /// Models APIs. + public ModelsApi Models { get; } + + /// Tools APIs. + public ToolsApi Tools { get; } + + /// Account APIs. + public AccountApi Account { get; } +} + +/// Server-scoped Models APIs. +public class ModelsApi +{ + private readonly JsonRpc _rpc; + + internal ModelsApi(JsonRpc rpc) + { + _rpc = rpc; + } + + /// Calls "models.list". + public async Task ListAsync(CancellationToken cancellationToken = default) + { + return await CopilotClient.InvokeRpcAsync(_rpc, "models.list", [], cancellationToken); + } +} + +/// Server-scoped Tools APIs. +public class ToolsApi +{ + private readonly JsonRpc _rpc; + + internal ToolsApi(JsonRpc rpc) + { + _rpc = rpc; + } + + /// Calls "tools.list". + public async Task ListAsync(string? model = null, CancellationToken cancellationToken = default) + { + var request = new ListRequest { Model = model }; + return await CopilotClient.InvokeRpcAsync(_rpc, "tools.list", [request], cancellationToken); + } +} + +/// Server-scoped Account APIs. +public class AccountApi +{ + private readonly JsonRpc _rpc; + + internal AccountApi(JsonRpc rpc) + { + _rpc = rpc; + } + + /// Calls "account.getQuota". + public async Task GetQuotaAsync(CancellationToken cancellationToken = default) + { + return await CopilotClient.InvokeRpcAsync(_rpc, "account.getQuota", [], cancellationToken); + } +} + +/// Typed session-scoped RPC methods. +public class SessionRpc +{ + private readonly JsonRpc _rpc; + private readonly string _sessionId; + + internal SessionRpc(JsonRpc rpc, string sessionId) + { + _rpc = rpc; + _sessionId = sessionId; + Model = new ModelApi(rpc, sessionId); + } + + public ModelApi Model { get; } +} + +public class ModelApi +{ + private readonly JsonRpc _rpc; + private readonly string _sessionId; + + internal ModelApi(JsonRpc rpc, string sessionId) + { + _rpc = rpc; + _sessionId = sessionId; + } + + /// Calls "session.model.getCurrent". + public async Task GetCurrentAsync(CancellationToken cancellationToken = default) + { + var request = new GetCurrentRequest { SessionId = _sessionId }; + return await CopilotClient.InvokeRpcAsync(_rpc, "session.model.getCurrent", [request], cancellationToken); + } + + /// Calls "session.model.switchTo". + public async Task SwitchToAsync(string modelId, CancellationToken cancellationToken = default) + { + var request = new SwitchToRequest { SessionId = _sessionId, ModelId = modelId }; + return await CopilotClient.InvokeRpcAsync(_rpc, "session.model.switchTo", [request], cancellationToken); + } +} + +[JsonSourceGenerationOptions( + JsonSerializerDefaults.Web, + AllowOutOfOrderMetadataProperties = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)] +[JsonSerializable(typeof(AccountGetQuotaResult))] +[JsonSerializable(typeof(AccountGetQuotaResultQuotaSnapshotsValue))] +[JsonSerializable(typeof(GetCurrentRequest))] +[JsonSerializable(typeof(ListRequest))] +[JsonSerializable(typeof(Model))] +[JsonSerializable(typeof(ModelBilling))] +[JsonSerializable(typeof(ModelCapabilities))] +[JsonSerializable(typeof(ModelCapabilitiesLimits))] +[JsonSerializable(typeof(ModelCapabilitiesSupports))] +[JsonSerializable(typeof(ModelPolicy))] +[JsonSerializable(typeof(ModelsListResult))] +[JsonSerializable(typeof(PingRequest))] +[JsonSerializable(typeof(PingResult))] +[JsonSerializable(typeof(SessionModelGetCurrentResult))] +[JsonSerializable(typeof(SessionModelSwitchToResult))] +[JsonSerializable(typeof(SwitchToRequest))] +[JsonSerializable(typeof(Tool))] +[JsonSerializable(typeof(ToolsListResult))] +internal partial class RpcJsonContext : JsonSerializerContext; \ No newline at end of file diff --git a/dotnet/src/Generated/SessionEvents.cs b/dotnet/src/Generated/SessionEvents.cs index 02258839..2d3ae978 100644 --- a/dotnet/src/Generated/SessionEvents.cs +++ b/dotnet/src/Generated/SessionEvents.cs @@ -3,14 +3,7 @@ *--------------------------------------------------------------------------------------------*/ // AUTO-GENERATED FILE - DO NOT EDIT -// -// Generated from: @github/copilot/session-events.schema.json -// Generated by: scripts/generate-session-types.ts -// Generated at: 2026-02-06T20:38:23.832Z -// -// To update these types: -// 1. Update the schema in copilot-agent-runtime -// 2. Run: npm run generate:session-types +// Generated from: session-events.schema.json using System.Text.Json; using System.Text.Json.Serialization; diff --git a/dotnet/src/Session.cs b/dotnet/src/Session.cs index aa2d5b04..1f8bfd4b 100644 --- a/dotnet/src/Session.cs +++ b/dotnet/src/Session.cs @@ -7,6 +7,7 @@ using System.Text.Json; using System.Text.Json.Nodes; using System.Text.Json.Serialization; +using GitHub.Copilot.SDK.Rpc; namespace GitHub.Copilot.SDK; @@ -52,6 +53,7 @@ public partial class CopilotSession : IAsyncDisposable private readonly SemaphoreSlim _userInputHandlerLock = new(1, 1); private SessionHooks? _hooks; private readonly SemaphoreSlim _hooksLock = new(1, 1); + private SessionRpc? _sessionRpc; /// /// Gets the unique identifier for this session. @@ -59,6 +61,11 @@ public partial class CopilotSession : IAsyncDisposable /// A string that uniquely identifies this session. public string SessionId { get; } + /// + /// Gets the typed RPC client for session-scoped methods. + /// + public SessionRpc Rpc => _sessionRpc ??= new SessionRpc(_rpc, SessionId); + /// /// Gets the path to the session workspace directory when infinite sessions are enabled. /// diff --git a/dotnet/test/RpcTests.cs b/dotnet/test/RpcTests.cs new file mode 100644 index 00000000..9d670295 --- /dev/null +++ b/dotnet/test/RpcTests.cs @@ -0,0 +1,82 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +using GitHub.Copilot.SDK.Test.Harness; +using Xunit; +using Xunit.Abstractions; + +namespace GitHub.Copilot.SDK.Test; + +public class RpcTests(E2ETestFixture fixture, ITestOutputHelper output) : E2ETestBase(fixture, "session", output) +{ + [Fact] + public async Task Should_Call_Rpc_Ping_With_Typed_Params_And_Result() + { + await Client.StartAsync(); + var result = await Client.Rpc.PingAsync(message: "typed rpc test"); + Assert.Equal("pong: typed rpc test", result.Message); + Assert.True(result.Timestamp >= 0); + } + + [Fact] + public async Task Should_Call_Rpc_Models_List_With_Typed_Result() + { + await Client.StartAsync(); + var authStatus = await Client.GetAuthStatusAsync(); + if (!authStatus.IsAuthenticated) + { + // Skip if not authenticated - models.list requires auth + return; + } + + var result = await Client.Rpc.Models.ListAsync(); + Assert.NotNull(result.Models); + } + + // account.getQuota is defined in schema but not yet implemented in CLI + [Fact(Skip = "account.getQuota not yet implemented in CLI")] + public async Task Should_Call_Rpc_Account_GetQuota_When_Authenticated() + { + await Client.StartAsync(); + var authStatus = await Client.GetAuthStatusAsync(); + if (!authStatus.IsAuthenticated) + { + // Skip if not authenticated - account.getQuota requires auth + return; + } + + var result = await Client.Rpc.Account.GetQuotaAsync(); + Assert.NotNull(result.QuotaSnapshots); + } + + // session.model.getCurrent is defined in schema but not yet implemented in CLI + [Fact(Skip = "session.model.getCurrent not yet implemented in CLI")] + public async Task Should_Call_Session_Rpc_Model_GetCurrent() + { + var session = await Client.CreateSessionAsync(new SessionConfig { Model = "claude-sonnet-4.5" }); + + var result = await session.Rpc.Model.GetCurrentAsync(); + Assert.NotNull(result.ModelId); + Assert.NotEmpty(result.ModelId); + } + + // session.model.switchTo is defined in schema but not yet implemented in CLI + [Fact(Skip = "session.model.switchTo not yet implemented in CLI")] + public async Task Should_Call_Session_Rpc_Model_SwitchTo() + { + var session = await Client.CreateSessionAsync(new SessionConfig { Model = "claude-sonnet-4.5" }); + + // Get initial model + var before = await session.Rpc.Model.GetCurrentAsync(); + Assert.NotNull(before.ModelId); + + // Switch to a different model + var result = await session.Rpc.Model.SwitchToAsync(modelId: "gpt-4.1"); + Assert.Equal("gpt-4.1", result.ModelId); + + // Verify the switch persisted + var after = await session.Rpc.Model.GetCurrentAsync(); + Assert.Equal("gpt-4.1", after.ModelId); + } +} diff --git a/go/client.go b/go/client.go index 1abc6ff5..8a071374 100644 --- a/go/client.go +++ b/go/client.go @@ -44,6 +44,7 @@ import ( "github.com/github/copilot-sdk/go/internal/embeddedcli" "github.com/github/copilot-sdk/go/internal/jsonrpc2" + "github.com/github/copilot-sdk/go/rpc" ) // Client manages the connection to the Copilot CLI server and provides session management. @@ -84,6 +85,10 @@ type Client struct { lifecycleHandlers []SessionLifecycleHandler typedLifecycleHandlers map[SessionLifecycleEventType][]SessionLifecycleHandler lifecycleHandlersMux sync.Mutex + + // RPC provides typed server-scoped RPC methods. + // This field is nil until the client is connected via Start(). + RPC *rpc.ServerRpc } // NewClient creates a new Copilot CLI client with the given options. @@ -337,6 +342,7 @@ func (c *Client) Stop() error { c.actualPort = 0 } + c.RPC = nil return errors.Join(errs...) } @@ -395,6 +401,8 @@ func (c *Client) ForceStop() { if !c.isExternalServer { c.actualPort = 0 } + + c.RPC = nil } func (c *Client) ensureConnected() error { @@ -1081,6 +1089,7 @@ func (c *Client) startCLIServer(ctx context.Context) error { // Create JSON-RPC client immediately c.client = jsonrpc2.NewClient(stdin, stdout) + c.RPC = rpc.NewServerRpc(c.client) c.setupNotificationHandler() c.client.Start() @@ -1153,6 +1162,7 @@ func (c *Client) connectViaTcp(ctx context.Context) error { // Create JSON-RPC client with the connection c.client = jsonrpc2.NewClient(conn, conn) + c.RPC = rpc.NewServerRpc(c.client) c.setupNotificationHandler() c.client.Start() diff --git a/go/generated_session_events.go b/go/generated_session_events.go index ec4de9be..ec440dc3 100644 --- a/go/generated_session_events.go +++ b/go/generated_session_events.go @@ -1,12 +1,5 @@ // AUTO-GENERATED FILE - DO NOT EDIT -// -// Generated from: @github/copilot/session-events.schema.json -// Generated by: scripts/generate-session-types.ts -// Generated at: 2026-02-06T20:38:23.463Z -// -// To update these types: -// 1. Update the schema in copilot-agent-runtime -// 2. Run: npm run generate:session-types +// Generated from: session-events.schema.json // Code generated from JSON Schema using quicktype. DO NOT EDIT. // To parse and unparse this JSON data, add this code to your project and do: diff --git a/go/internal/e2e/rpc_test.go b/go/internal/e2e/rpc_test.go new file mode 100644 index 00000000..07916646 --- /dev/null +++ b/go/internal/e2e/rpc_test.go @@ -0,0 +1,188 @@ +package e2e + +import ( + "testing" + + copilot "github.com/github/copilot-sdk/go" + "github.com/github/copilot-sdk/go/internal/e2e/testharness" + "github.com/github/copilot-sdk/go/rpc" +) + +func TestRpc(t *testing.T) { + cliPath := testharness.CLIPath() + if cliPath == "" { + t.Fatal("CLI not found. Run 'npm install' in the nodejs directory first.") + } + + t.Run("should call RPC.Ping with typed params and result", func(t *testing.T) { + client := copilot.NewClient(&copilot.ClientOptions{ + CLIPath: cliPath, + UseStdio: copilot.Bool(true), + }) + t.Cleanup(func() { client.ForceStop() }) + + if err := client.Start(t.Context()); err != nil { + t.Fatalf("Failed to start client: %v", err) + } + + result, err := client.RPC.Ping(t.Context(), &rpc.PingParams{Message: copilot.String("typed rpc test")}) + if err != nil { + t.Fatalf("Failed to call RPC.Ping: %v", err) + } + + if result.Message != "pong: typed rpc test" { + t.Errorf("Expected message 'pong: typed rpc test', got %q", result.Message) + } + + if result.Timestamp < 0 { + t.Errorf("Expected timestamp >= 0, got %f", result.Timestamp) + } + + if err := client.Stop(); err != nil { + t.Errorf("Expected no errors on stop, got %v", err) + } + }) + + t.Run("should call RPC.Models.List with typed result", func(t *testing.T) { + client := copilot.NewClient(&copilot.ClientOptions{ + CLIPath: cliPath, + UseStdio: copilot.Bool(true), + }) + t.Cleanup(func() { client.ForceStop() }) + + if err := client.Start(t.Context()); err != nil { + t.Fatalf("Failed to start client: %v", err) + } + + authStatus, err := client.GetAuthStatus(t.Context()) + if err != nil { + t.Fatalf("Failed to get auth status: %v", err) + } + + if !authStatus.IsAuthenticated { + t.Skip("Not authenticated - skipping models.list test") + } + + result, err := client.RPC.Models.List(t.Context()) + if err != nil { + t.Fatalf("Failed to call RPC.Models.List: %v", err) + } + + if result.Models == nil { + t.Error("Expected models to be defined") + } + + if err := client.Stop(); err != nil { + t.Errorf("Expected no errors on stop, got %v", err) + } + }) + + // account.getQuota is defined in schema but not yet implemented in CLI + t.Run("should call RPC.Account.GetQuota when authenticated", func(t *testing.T) { + t.Skip("account.getQuota not yet implemented in CLI") + + client := copilot.NewClient(&copilot.ClientOptions{ + CLIPath: cliPath, + UseStdio: copilot.Bool(true), + }) + t.Cleanup(func() { client.ForceStop() }) + + if err := client.Start(t.Context()); err != nil { + t.Fatalf("Failed to start client: %v", err) + } + + authStatus, err := client.GetAuthStatus(t.Context()) + if err != nil { + t.Fatalf("Failed to get auth status: %v", err) + } + + if !authStatus.IsAuthenticated { + t.Skip("Not authenticated - skipping account.getQuota test") + } + + result, err := client.RPC.Account.GetQuota(t.Context()) + if err != nil { + t.Fatalf("Failed to call RPC.Account.GetQuota: %v", err) + } + + if result.QuotaSnapshots == nil { + t.Error("Expected quotaSnapshots to be defined") + } + + if err := client.Stop(); err != nil { + t.Errorf("Expected no errors on stop, got %v", err) + } + }) +} + +func TestSessionRpc(t *testing.T) { + ctx := testharness.NewTestContext(t) + client := ctx.NewClient() + t.Cleanup(func() { client.ForceStop() }) + + if err := client.Start(t.Context()); err != nil { + t.Fatalf("Failed to start client: %v", err) + } + + // session.model.getCurrent is defined in schema but not yet implemented in CLI + t.Run("should call session.RPC.Model.GetCurrent", func(t *testing.T) { + t.Skip("session.model.getCurrent not yet implemented in CLI") + + session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + Model: "claude-sonnet-4.5", + }) + if err != nil { + t.Fatalf("Failed to create session: %v", err) + } + + result, err := session.RPC.Model.GetCurrent(t.Context()) + if err != nil { + t.Fatalf("Failed to call session.RPC.Model.GetCurrent: %v", err) + } + + if result.ModelID == nil || *result.ModelID == "" { + t.Error("Expected modelId to be defined") + } + }) + + // session.model.switchTo is defined in schema but not yet implemented in CLI + t.Run("should call session.RPC.Model.SwitchTo", func(t *testing.T) { + t.Skip("session.model.switchTo not yet implemented in CLI") + + session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + Model: "claude-sonnet-4.5", + }) + if err != nil { + t.Fatalf("Failed to create session: %v", err) + } + + // Get initial model + before, err := session.RPC.Model.GetCurrent(t.Context()) + if err != nil { + t.Fatalf("Failed to get current model: %v", err) + } + if before.ModelID == nil || *before.ModelID == "" { + t.Error("Expected initial modelId to be defined") + } + + // Switch to a different model + result, err := session.RPC.Model.SwitchTo(t.Context(), &rpc.SessionModelSwitchToParams{ + ModelID: "gpt-4.1", + }) + if err != nil { + t.Fatalf("Failed to switch model: %v", err) + } + if result.ModelID == nil || *result.ModelID != "gpt-4.1" { + t.Errorf("Expected modelId 'gpt-4.1', got %v", result.ModelID) + } + + // Verify the switch persisted + after, err := session.RPC.Model.GetCurrent(t.Context()) + if err != nil { + t.Fatalf("Failed to get current model after switch: %v", err) + } + if after.ModelID == nil || *after.ModelID != "gpt-4.1" { + t.Errorf("Expected modelId 'gpt-4.1' after switch, got %v", after.ModelID) + } + }) +} diff --git a/go/rpc/generated_rpc.go b/go/rpc/generated_rpc.go new file mode 100644 index 00000000..bca3859e --- /dev/null +++ b/go/rpc/generated_rpc.go @@ -0,0 +1,250 @@ +// AUTO-GENERATED FILE - DO NOT EDIT +// Generated from: api.schema.json + +package rpc + +import ( + "context" + "encoding/json" + + "github.com/github/copilot-sdk/go/internal/jsonrpc2" +) + +type PingResult struct { + // Echoed message (or default greeting) + Message string `json:"message"` + // Server protocol version number + ProtocolVersion float64 `json:"protocolVersion"` + // Server timestamp in milliseconds + Timestamp float64 `json:"timestamp"` +} + +type PingParams struct { + // Optional message to echo back + Message *string `json:"message,omitempty"` +} + +type ModelsListResult struct { + // List of available models with full metadata + Models []Model `json:"models"` +} + +type Model struct { + // Billing information + Billing *Billing `json:"billing,omitempty"` + // Model capabilities and limits + Capabilities Capabilities `json:"capabilities"` + // Default reasoning effort level (only present if model supports reasoning effort) + DefaultReasoningEffort *string `json:"defaultReasoningEffort,omitempty"` + // Model identifier (e.g., "claude-sonnet-4.5") + ID string `json:"id"` + // Display name + Name string `json:"name"` + // Policy state (if applicable) + Policy *Policy `json:"policy,omitempty"` + // Supported reasoning effort levels (only present if model supports reasoning effort) + SupportedReasoningEfforts []string `json:"supportedReasoningEfforts,omitempty"` +} + +// Billing information +type Billing struct { + Multiplier float64 `json:"multiplier"` +} + +// Model capabilities and limits +type Capabilities struct { + Limits Limits `json:"limits"` + Supports Supports `json:"supports"` +} + +type Limits struct { + MaxContextWindowTokens float64 `json:"max_context_window_tokens"` + MaxOutputTokens *float64 `json:"max_output_tokens,omitempty"` + MaxPromptTokens *float64 `json:"max_prompt_tokens,omitempty"` +} + +type Supports struct { + // Whether this model supports reasoning effort configuration + ReasoningEffort bool `json:"reasoningEffort"` + Vision bool `json:"vision"` +} + +// Policy state (if applicable) +type Policy struct { + State string `json:"state"` + Terms string `json:"terms"` +} + +type ToolsListResult struct { + // List of available built-in tools with metadata + Tools []Tool `json:"tools"` +} + +type Tool struct { + // Description of what the tool does + Description string `json:"description"` + // Optional instructions for how to use this tool effectively + Instructions *string `json:"instructions,omitempty"` + // Tool identifier (e.g., "bash", "grep", "str_replace_editor") + Name string `json:"name"` + // Optional namespaced name for declarative filtering (e.g., "playwright/navigate" for MCP + // tools) + NamespacedName *string `json:"namespacedName,omitempty"` + // JSON Schema for the tool's input parameters + Parameters map[string]interface{} `json:"parameters,omitempty"` +} + +type ToolsListParams struct { + // Optional model ID — when provided, the returned tool list reflects model-specific + // overrides + Model *string `json:"model,omitempty"` +} + +type AccountGetQuotaResult struct { + // Quota snapshots keyed by type (e.g., chat, completions, premium_interactions) + QuotaSnapshots map[string]QuotaSnapshot `json:"quotaSnapshots"` +} + +type QuotaSnapshot struct { + // Number of requests included in the entitlement + EntitlementRequests float64 `json:"entitlementRequests"` + // Number of overage requests made this period + Overage float64 `json:"overage"` + // Whether pay-per-request usage is allowed when quota is exhausted + OverageAllowedWithExhaustedQuota bool `json:"overageAllowedWithExhaustedQuota"` + // Percentage of entitlement remaining + RemainingPercentage float64 `json:"remainingPercentage"` + // Date when the quota resets (ISO 8601) + ResetDate *string `json:"resetDate,omitempty"` + // Number of requests used so far this period + UsedRequests float64 `json:"usedRequests"` +} + +type SessionModelGetCurrentResult struct { + ModelID *string `json:"modelId,omitempty"` +} + +type SessionModelSwitchToResult struct { + ModelID *string `json:"modelId,omitempty"` +} + +type SessionModelSwitchToParams struct { + ModelID string `json:"modelId"` +} + +type ModelsRpcApi struct{ client *jsonrpc2.Client } + +func (a *ModelsRpcApi) List(ctx context.Context) (*ModelsListResult, error) { + raw, err := a.client.Request("models.list", map[string]interface{}{}) + if err != nil { + return nil, err + } + var result ModelsListResult + if err := json.Unmarshal(raw, &result); err != nil { + return nil, err + } + return &result, nil +} + +type ToolsRpcApi struct{ client *jsonrpc2.Client } + +func (a *ToolsRpcApi) List(ctx context.Context, params *ToolsListParams) (*ToolsListResult, error) { + raw, err := a.client.Request("tools.list", params) + if err != nil { + return nil, err + } + var result ToolsListResult + if err := json.Unmarshal(raw, &result); err != nil { + return nil, err + } + return &result, nil +} + +type AccountRpcApi struct{ client *jsonrpc2.Client } + +func (a *AccountRpcApi) GetQuota(ctx context.Context) (*AccountGetQuotaResult, error) { + raw, err := a.client.Request("account.getQuota", map[string]interface{}{}) + if err != nil { + return nil, err + } + var result AccountGetQuotaResult + if err := json.Unmarshal(raw, &result); err != nil { + return nil, err + } + return &result, nil +} + +// ServerRpc provides typed server-scoped RPC methods. +type ServerRpc struct { + client *jsonrpc2.Client + Models *ModelsRpcApi + Tools *ToolsRpcApi + Account *AccountRpcApi +} + +func (a *ServerRpc) Ping(ctx context.Context, params *PingParams) (*PingResult, error) { + raw, err := a.client.Request("ping", params) + if err != nil { + return nil, err + } + var result PingResult + if err := json.Unmarshal(raw, &result); err != nil { + return nil, err + } + return &result, nil +} + +func NewServerRpc(client *jsonrpc2.Client) *ServerRpc { + return &ServerRpc{client: client, + Models: &ModelsRpcApi{client: client}, + Tools: &ToolsRpcApi{client: client}, + Account: &AccountRpcApi{client: client}, + } +} + +type ModelRpcApi struct { + client *jsonrpc2.Client + sessionID string +} + +func (a *ModelRpcApi) GetCurrent(ctx context.Context) (*SessionModelGetCurrentResult, error) { + req := map[string]interface{}{"sessionId": a.sessionID} + raw, err := a.client.Request("session.model.getCurrent", req) + if err != nil { + return nil, err + } + var result SessionModelGetCurrentResult + if err := json.Unmarshal(raw, &result); err != nil { + return nil, err + } + return &result, nil +} + +func (a *ModelRpcApi) SwitchTo(ctx context.Context, params *SessionModelSwitchToParams) (*SessionModelSwitchToResult, error) { + req := map[string]interface{}{"sessionId": a.sessionID} + if params != nil { + req["modelId"] = params.ModelID + } + raw, err := a.client.Request("session.model.switchTo", req) + if err != nil { + return nil, err + } + var result SessionModelSwitchToResult + if err := json.Unmarshal(raw, &result); err != nil { + return nil, err + } + return &result, nil +} + +// SessionRpc provides typed session-scoped RPC methods. +type SessionRpc struct { + client *jsonrpc2.Client + sessionID string + Model *ModelRpcApi +} + +func NewSessionRpc(client *jsonrpc2.Client, sessionID string) *SessionRpc { + return &SessionRpc{client: client, sessionID: sessionID, + Model: &ModelRpcApi{client: client, sessionID: sessionID}, + } +} diff --git a/go/session.go b/go/session.go index 37cfe52f..ce1a3eff 100644 --- a/go/session.go +++ b/go/session.go @@ -9,6 +9,7 @@ import ( "time" "github.com/github/copilot-sdk/go/internal/jsonrpc2" + "github.com/github/copilot-sdk/go/rpc" ) type sessionHandler struct { @@ -63,6 +64,9 @@ type Session struct { userInputMux sync.RWMutex hooks *SessionHooks hooksMux sync.RWMutex + + // RPC provides typed session-scoped RPC methods. + RPC *rpc.SessionRpc } // WorkspacePath returns the path to the session workspace directory when infinite @@ -80,6 +84,7 @@ func newSession(sessionID string, client *jsonrpc2.Client, workspacePath string) client: client, handlers: make([]sessionHandler, 0), toolHandlers: make(map[string]ToolHandler), + RPC: rpc.NewSessionRpc(client, sessionID), } } diff --git a/go/types.go b/go/types.go index a3b38ee3..9d9a18c6 100644 --- a/go/types.go +++ b/go/types.go @@ -60,6 +60,12 @@ func Bool(v bool) *bool { return &v } +// String returns a pointer to the given string value. +// Use for setting optional string parameters in RPC calls. +func String(v string) *string { + return &v +} + // Float64 returns a pointer to the given float64 value. // Use for setting thresholds: BackgroundCompactionThreshold: Float64(0.80) func Float64(v float64) *float64 { diff --git a/nodejs/package.json b/nodejs/package.json index b6e23f40..da9702a6 100644 --- a/nodejs/package.json +++ b/nodejs/package.json @@ -25,7 +25,7 @@ "lint": "eslint \"src/**/*.ts\" \"test/**/*.ts\"", "lint:fix": "eslint --fix \"src/**/*.ts\" \"test/**/*.ts\"", "typecheck": "tsc --noEmit", - "generate:session-types": "tsx scripts/generate-session-types.ts", + "generate": "cd ../scripts/codegen && npm run generate", "update:protocol-version": "tsx scripts/update-protocol-version.ts", "prepublishOnly": "npm run build", "package": "npm run clean && npm run build && node scripts/set-version.js && npm pack && npm version 0.1.0 --no-git-tag-version --allow-same-version" diff --git a/nodejs/scripts/generate-csharp-session-types.ts b/nodejs/scripts/generate-csharp-session-types.ts deleted file mode 100644 index cf295117..00000000 --- a/nodejs/scripts/generate-csharp-session-types.ts +++ /dev/null @@ -1,795 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - *--------------------------------------------------------------------------------------------*/ - -/** - * Custom C# code generator for session event types with proper polymorphic serialization. - * - * This generator produces: - * - A base SessionEvent class with [JsonPolymorphic] and [JsonDerivedType] attributes - * - Separate event classes (SessionStartEvent, AssistantMessageEvent, etc.) with strongly-typed Data - * - Separate Data classes for each event type with only the relevant properties - * - * This approach provides type-safe access to event data instead of a single Data class with 60+ nullable properties. - */ - -import type { JSONSchema7 } from "json-schema"; - -interface EventVariant { - typeName: string; // e.g., "session.start" - className: string; // e.g., "SessionStartEvent" - dataClassName: string; // e.g., "SessionStartData" - dataSchema: JSONSchema7; - ephemeralConst?: boolean; // if ephemeral has a const value -} - -/** - * Convert a type string like "session.start" to PascalCase class name like "SessionStart" - */ -function typeToClassName(typeName: string): string { - return typeName - .split(/[._]/) - .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) - .join(""); -} - -/** - * Convert a property name to PascalCase for C# - */ -function toPascalCase(name: string): string { - // Handle snake_case - if (name.includes("_")) { - return name - .split("_") - .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) - .join(""); - } - // Handle camelCase - return name.charAt(0).toUpperCase() + name.slice(1); -} - -/** - * Map JSON Schema type to C# type - */ -function schemaTypeToCSharp( - schema: JSONSchema7, - required: boolean, - knownTypes: Map, - parentClassName?: string, - propName?: string, - enumOutput?: string[] -): string { - if (schema.anyOf) { - // Handle nullable types (anyOf with null) - const nonNull = schema.anyOf.filter((s) => typeof s === "object" && s.type !== "null"); - if (nonNull.length === 1 && typeof nonNull[0] === "object") { - return ( - schemaTypeToCSharp( - nonNull[0] as JSONSchema7, - false, - knownTypes, - parentClassName, - propName, - enumOutput - ) + "?" - ); - } - } - - if (schema.enum && parentClassName && propName && enumOutput) { - // Generate C# enum - const enumName = getOrCreateEnum( - parentClassName, - propName, - schema.enum as string[], - enumOutput - ); - return required ? enumName : `${enumName}?`; - } - - if (schema.$ref) { - const refName = schema.$ref.split("/").pop()!; - return knownTypes.get(refName) || refName; - } - - const type = schema.type; - const format = schema.format; - - if (type === "string") { - if (format === "uuid") return required ? "Guid" : "Guid?"; - if (format === "date-time") return required ? "DateTimeOffset" : "DateTimeOffset?"; - return required ? "string" : "string?"; - } - if (type === "number" || type === "integer") { - return required ? "double" : "double?"; - } - if (type === "boolean") { - return required ? "bool" : "bool?"; - } - if (type === "array") { - const items = schema.items as JSONSchema7 | undefined; - const itemType = items ? schemaTypeToCSharp(items, true, knownTypes) : "object"; - return required ? `${itemType}[]` : `${itemType}[]?`; - } - if (type === "object") { - if (schema.additionalProperties) { - const valueSchema = schema.additionalProperties; - if (typeof valueSchema === "object") { - const valueType = schemaTypeToCSharp(valueSchema as JSONSchema7, true, knownTypes); - return required ? `Dictionary` : `Dictionary?`; - } - return required ? "Dictionary" : "Dictionary?"; - } - return required ? "object" : "object?"; - } - - return required ? "object" : "object?"; -} - -/** - * Event types to exclude from generation (internal/legacy types) - */ -const EXCLUDED_EVENT_TYPES = new Set(["session.import_legacy"]); - -/** - * Track enums that have been generated to avoid duplicates - */ -const generatedEnums = new Map(); - -/** - * Generate a C# enum name from the context - */ -function generateEnumName(parentClassName: string, propName: string): string { - return `${parentClassName}${propName}`; -} - -/** - * Get or create an enum for a given set of values. - * Returns the enum name and whether it's newly generated. - */ -function getOrCreateEnum( - parentClassName: string, - propName: string, - values: string[], - enumOutput: string[] -): string { - // Create a key based on the sorted values to detect duplicates - const valuesKey = [...values].sort().join("|"); - - // Check if we already have an enum with these exact values - for (const [, existing] of generatedEnums) { - const existingKey = [...existing.values].sort().join("|"); - if (existingKey === valuesKey) { - return existing.enumName; - } - } - - const enumName = generateEnumName(parentClassName, propName); - generatedEnums.set(enumName, { enumName, values }); - - // Generate the enum code with JsonConverter and JsonStringEnumMemberName attributes - const lines: string[] = []; - lines.push(`[JsonConverter(typeof(JsonStringEnumConverter<${enumName}>))]`); - lines.push(`public enum ${enumName}`); - lines.push(`{`); - for (const value of values) { - const memberName = toPascalCaseEnumMember(value); - lines.push(` [JsonStringEnumMemberName("${value}")]`); - lines.push(` ${memberName},`); - } - lines.push(`}`); - lines.push(""); - - enumOutput.push(lines.join("\n")); - return enumName; -} - -/** - * Convert a string value to a valid C# enum member name - */ -function toPascalCaseEnumMember(value: string): string { - // Handle special characters and convert to PascalCase - return value - .split(/[-_.]/) - .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) - .join(""); -} - -/** - * Extract event variants from the schema's anyOf - */ -function extractEventVariants(schema: JSONSchema7): EventVariant[] { - const sessionEvent = schema.definitions?.SessionEvent as JSONSchema7; - if (!sessionEvent?.anyOf) { - throw new Error("Schema must have SessionEvent definition with anyOf"); - } - - return sessionEvent.anyOf - .map((variant) => { - if (typeof variant !== "object" || !variant.properties) { - throw new Error("Invalid variant in anyOf"); - } - - const typeSchema = variant.properties.type as JSONSchema7; - const typeName = typeSchema?.const as string; - if (!typeName) { - throw new Error("Variant must have type.const"); - } - - const baseName = typeToClassName(typeName); - const ephemeralSchema = variant.properties.ephemeral as JSONSchema7 | undefined; - - return { - typeName, - className: `${baseName}Event`, - dataClassName: `${baseName}Data`, - dataSchema: variant.properties.data as JSONSchema7, - ephemeralConst: ephemeralSchema?.const as boolean | undefined, - }; - }) - .filter((variant) => !EXCLUDED_EVENT_TYPES.has(variant.typeName)); -} - -/** - * Generate C# code for a Data class - */ -function generateDataClass( - variant: EventVariant, - knownTypes: Map, - nestedClasses: Map, - enumOutput: string[] -): string { - const lines: string[] = []; - const dataSchema = variant.dataSchema; - - if (!dataSchema?.properties) { - lines.push(`public partial class ${variant.dataClassName} { }`); - return lines.join("\n"); - } - - const required = new Set(dataSchema.required || []); - - lines.push(`public partial class ${variant.dataClassName}`); - lines.push(`{`); - - for (const [propName, propSchema] of Object.entries(dataSchema.properties)) { - if (typeof propSchema !== "object") continue; - - const isRequired = required.has(propName); - const csharpName = toPascalCase(propName); - const csharpType = resolvePropertyType( - propSchema as JSONSchema7, - variant.dataClassName, - csharpName, - isRequired, - knownTypes, - nestedClasses, - enumOutput - ); - - const isNullableType = csharpType.endsWith("?"); - if (!isRequired) { - lines.push( - ` [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]` - ); - } - lines.push(` [JsonPropertyName("${propName}")]`); - - const requiredModifier = isRequired && !isNullableType ? "required " : ""; - lines.push(` public ${requiredModifier}${csharpType} ${csharpName} { get; set; }`); - lines.push(""); - } - - // Remove trailing empty line - if (lines[lines.length - 1] === "") { - lines.pop(); - } - - lines.push(`}`); - return lines.join("\n"); -} - -/** - * Generate a nested class for complex object properties. - * This function recursively handles nested objects, arrays of objects, and anyOf unions. - */ -function generateNestedClass( - className: string, - schema: JSONSchema7, - knownTypes: Map, - nestedClasses: Map, - enumOutput: string[] -): string { - const lines: string[] = []; - const required = new Set(schema.required || []); - - lines.push(`public partial class ${className}`); - lines.push(`{`); - - if (schema.properties) { - for (const [propName, propSchema] of Object.entries(schema.properties)) { - if (typeof propSchema !== "object") continue; - - const isRequired = required.has(propName); - const csharpName = toPascalCase(propName); - let csharpType = resolvePropertyType( - propSchema as JSONSchema7, - className, - csharpName, - isRequired, - knownTypes, - nestedClasses, - enumOutput - ); - - if (!isRequired) { - lines.push( - ` [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]` - ); - } - lines.push(` [JsonPropertyName("${propName}")]`); - - const isNullableType = csharpType.endsWith("?"); - const requiredModifier = isRequired && !isNullableType ? "required " : ""; - lines.push(` public ${requiredModifier}${csharpType} ${csharpName} { get; set; }`); - lines.push(""); - } - } - - // Remove trailing empty line - if (lines[lines.length - 1] === "") { - lines.pop(); - } - - lines.push(`}`); - return lines.join("\n"); -} - -/** - * Find a discriminator property shared by all variants in an anyOf. - * Returns the property name and the mapping of const values to variant schemas. - */ -function findDiscriminator(variants: JSONSchema7[]): { property: string; mapping: Map } | null { - if (variants.length === 0) return null; - - // Look for a property with a const value in all variants - const firstVariant = variants[0]; - if (!firstVariant.properties) return null; - - for (const [propName, propSchema] of Object.entries(firstVariant.properties)) { - if (typeof propSchema !== "object") continue; - const schema = propSchema as JSONSchema7; - if (schema.const === undefined) continue; - - // Check if all variants have this property with a const value - const mapping = new Map(); - let isValidDiscriminator = true; - - for (const variant of variants) { - if (!variant.properties) { - isValidDiscriminator = false; - break; - } - const variantProp = variant.properties[propName]; - if (typeof variantProp !== "object") { - isValidDiscriminator = false; - break; - } - const variantSchema = variantProp as JSONSchema7; - if (variantSchema.const === undefined) { - isValidDiscriminator = false; - break; - } - mapping.set(String(variantSchema.const), variant); - } - - if (isValidDiscriminator && mapping.size === variants.length) { - return { property: propName, mapping }; - } - } - - return null; -} - -/** - * Generate a polymorphic base class and derived classes for a discriminated union. - */ -function generatePolymorphicClasses( - baseClassName: string, - discriminatorProperty: string, - variants: JSONSchema7[], - knownTypes: Map, - nestedClasses: Map, - enumOutput: string[] -): string { - const lines: string[] = []; - const discriminatorInfo = findDiscriminator(variants)!; - - // Generate base class with JsonPolymorphic attribute - lines.push(`[JsonPolymorphic(`); - lines.push(` TypeDiscriminatorPropertyName = "${discriminatorProperty}",`); - lines.push(` UnknownDerivedTypeHandling = JsonUnknownDerivedTypeHandling.FallBackToBaseType)]`); - - // Add JsonDerivedType attributes for each variant - for (const [constValue] of discriminatorInfo.mapping) { - const derivedClassName = `${baseClassName}${toPascalCase(constValue)}`; - lines.push(`[JsonDerivedType(typeof(${derivedClassName}), "${constValue}")]`); - } - - lines.push(`public partial class ${baseClassName}`); - lines.push(`{`); - lines.push(` [JsonPropertyName("${discriminatorProperty}")]`); - lines.push(` public virtual string ${toPascalCase(discriminatorProperty)} { get; set; } = string.Empty;`); - lines.push(`}`); - lines.push(""); - - // Generate derived classes - for (const [constValue, variant] of discriminatorInfo.mapping) { - const derivedClassName = `${baseClassName}${toPascalCase(constValue)}`; - const derivedCode = generateDerivedClass( - derivedClassName, - baseClassName, - discriminatorProperty, - constValue, - variant, - knownTypes, - nestedClasses, - enumOutput - ); - nestedClasses.set(derivedClassName, derivedCode); - } - - return lines.join("\n"); -} - -/** - * Generate a derived class for a discriminated union variant. - */ -function generateDerivedClass( - className: string, - baseClassName: string, - discriminatorProperty: string, - discriminatorValue: string, - schema: JSONSchema7, - knownTypes: Map, - nestedClasses: Map, - enumOutput: string[] -): string { - const lines: string[] = []; - const required = new Set(schema.required || []); - - lines.push(`public partial class ${className} : ${baseClassName}`); - lines.push(`{`); - - // Override the discriminator property - lines.push(` [JsonIgnore]`); - lines.push(` public override string ${toPascalCase(discriminatorProperty)} => "${discriminatorValue}";`); - lines.push(""); - - if (schema.properties) { - for (const [propName, propSchema] of Object.entries(schema.properties)) { - if (typeof propSchema !== "object") continue; - // Skip the discriminator property (already in base class) - if (propName === discriminatorProperty) continue; - - const isRequired = required.has(propName); - const csharpName = toPascalCase(propName); - const csharpType = resolvePropertyType( - propSchema as JSONSchema7, - className, - csharpName, - isRequired, - knownTypes, - nestedClasses, - enumOutput - ); - - if (!isRequired) { - lines.push(` [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]`); - } - lines.push(` [JsonPropertyName("${propName}")]`); - - const isNullableType = csharpType.endsWith("?"); - const requiredModifier = isRequired && !isNullableType ? "required " : ""; - lines.push(` public ${requiredModifier}${csharpType} ${csharpName} { get; set; }`); - lines.push(""); - } - } - - // Remove trailing empty line - if (lines[lines.length - 1] === "") { - lines.pop(); - } - - lines.push(`}`); - return lines.join("\n"); -} - -/** - * Resolve the C# type for a property, generating nested classes as needed. - * Handles objects and arrays of objects. - */ -function resolvePropertyType( - propSchema: JSONSchema7, - parentClassName: string, - propName: string, - isRequired: boolean, - knownTypes: Map, - nestedClasses: Map, - enumOutput: string[] -): string { - // Handle anyOf - simplify to nullable of the non-null type or object - if (propSchema.anyOf) { - const hasNull = propSchema.anyOf.some( - (s) => typeof s === "object" && (s as JSONSchema7).type === "null" - ); - const nonNullTypes = propSchema.anyOf.filter( - (s) => typeof s === "object" && (s as JSONSchema7).type !== "null" - ); - if (nonNullTypes.length === 1) { - // Simple nullable - recurse with the inner type, marking as not required if null is an option - return resolvePropertyType( - nonNullTypes[0] as JSONSchema7, - parentClassName, - propName, - isRequired && !hasNull, - knownTypes, - nestedClasses, - enumOutput - ); - } - // Complex union - use object, nullable if null is in the union or property is not required - return (hasNull || !isRequired) ? "object?" : "object"; - } - - // Handle enum types - if (propSchema.enum && Array.isArray(propSchema.enum)) { - const enumName = getOrCreateEnum( - parentClassName, - propName, - propSchema.enum as string[], - enumOutput - ); - return isRequired ? enumName : `${enumName}?`; - } - - // Handle nested object types - if (propSchema.type === "object" && propSchema.properties) { - const nestedClassName = `${parentClassName}${propName}`; - const nestedCode = generateNestedClass( - nestedClassName, - propSchema, - knownTypes, - nestedClasses, - enumOutput - ); - nestedClasses.set(nestedClassName, nestedCode); - return isRequired ? nestedClassName : `${nestedClassName}?`; - } - - // Handle array of objects - if (propSchema.type === "array" && propSchema.items) { - const items = propSchema.items as JSONSchema7; - - // Array of discriminated union (anyOf with shared discriminator) - if (items.anyOf && Array.isArray(items.anyOf)) { - const variants = items.anyOf.filter((v): v is JSONSchema7 => typeof v === "object"); - const discriminatorInfo = findDiscriminator(variants); - - if (discriminatorInfo) { - const baseClassName = `${parentClassName}${propName}Item`; - const polymorphicCode = generatePolymorphicClasses( - baseClassName, - discriminatorInfo.property, - variants, - knownTypes, - nestedClasses, - enumOutput - ); - nestedClasses.set(baseClassName, polymorphicCode); - return isRequired ? `${baseClassName}[]` : `${baseClassName}[]?`; - } - } - - // Array of objects with properties - if (items.type === "object" && items.properties) { - const itemClassName = `${parentClassName}${propName}Item`; - const nestedCode = generateNestedClass( - itemClassName, - items, - knownTypes, - nestedClasses, - enumOutput - ); - nestedClasses.set(itemClassName, nestedCode); - return isRequired ? `${itemClassName}[]` : `${itemClassName}[]?`; - } - - // Array of enums - if (items.enum && Array.isArray(items.enum)) { - const enumName = getOrCreateEnum( - parentClassName, - `${propName}Item`, - items.enum as string[], - enumOutput - ); - return isRequired ? `${enumName}[]` : `${enumName}[]?`; - } - - // Simple array type - const itemType = schemaTypeToCSharp( - items, - true, - knownTypes, - parentClassName, - propName, - enumOutput - ); - return isRequired ? `${itemType}[]` : `${itemType}[]?`; - } - - // Default: use basic type mapping - return schemaTypeToCSharp( - propSchema, - isRequired, - knownTypes, - parentClassName, - propName, - enumOutput - ); -} - -/** - * Generate the complete C# file - */ -export function generateCSharpSessionTypes(schema: JSONSchema7, generatedAt: string): string { - // Clear the generated enums map from any previous run - generatedEnums.clear(); - - const variants = extractEventVariants(schema); - const knownTypes = new Map(); - const nestedClasses = new Map(); - const enumOutput: string[] = []; - - const lines: string[] = []; - - // File header - lines.push(`/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - *--------------------------------------------------------------------------------------------*/ - -// AUTO-GENERATED FILE - DO NOT EDIT -// -// Generated from: @github/copilot/session-events.schema.json -// Generated by: scripts/generate-session-types.ts -// Generated at: ${generatedAt} -// -// To update these types: -// 1. Update the schema in copilot-agent-runtime -// 2. Run: npm run generate:session-types - -using System.Text.Json; -using System.Text.Json.Serialization; - -namespace GitHub.Copilot.SDK; -`); - - // Generate base class with JsonPolymorphic attributes - lines.push(`/// `); - lines.push( - `/// Base class for all session events with polymorphic JSON serialization.` - ); - lines.push(`/// `); - lines.push(`[JsonPolymorphic(`); - lines.push(` TypeDiscriminatorPropertyName = "type",`); - lines.push( - ` UnknownDerivedTypeHandling = JsonUnknownDerivedTypeHandling.FailSerialization)]` - ); - - // Generate JsonDerivedType attributes for each variant (alphabetized) - for (const variant of [...variants].sort((a, b) => a.typeName.localeCompare(b.typeName))) { - lines.push( - `[JsonDerivedType(typeof(${variant.className}), "${variant.typeName}")]` - ); - } - - lines.push(`public abstract partial class SessionEvent`); - lines.push(`{`); - lines.push(` [JsonPropertyName("id")]`); - lines.push(` public Guid Id { get; set; }`); - lines.push(""); - lines.push(` [JsonPropertyName("timestamp")]`); - lines.push(` public DateTimeOffset Timestamp { get; set; }`); - lines.push(""); - lines.push(` [JsonPropertyName("parentId")]`); - lines.push(` public Guid? ParentId { get; set; }`); - lines.push(""); - lines.push(` [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]`); - lines.push(` [JsonPropertyName("ephemeral")]`); - lines.push(` public bool? Ephemeral { get; set; }`); - lines.push(""); - lines.push(` /// `); - lines.push(` /// The event type discriminator.`); - lines.push(` /// `); - lines.push(` [JsonIgnore]`); - lines.push(` public abstract string Type { get; }`); - lines.push(""); - lines.push(` public static SessionEvent FromJson(string json) =>`); - lines.push( - ` JsonSerializer.Deserialize(json, SessionEventsJsonContext.Default.SessionEvent)!;` - ); - lines.push(""); - lines.push(` public string ToJson() =>`); - lines.push( - ` JsonSerializer.Serialize(this, SessionEventsJsonContext.Default.SessionEvent);` - ); - lines.push(`}`); - lines.push(""); - - // Generate each event class - for (const variant of variants) { - lines.push(`/// `); - lines.push(`/// Event: ${variant.typeName}`); - lines.push(`/// `); - lines.push(`public partial class ${variant.className} : SessionEvent`); - lines.push(`{`); - lines.push(` [JsonIgnore]`); - lines.push(` public override string Type => "${variant.typeName}";`); - lines.push(""); - lines.push(` [JsonPropertyName("data")]`); - lines.push(` public required ${variant.dataClassName} Data { get; set; }`); - lines.push(`}`); - lines.push(""); - } - - // Generate data classes - for (const variant of variants) { - const dataClass = generateDataClass(variant, knownTypes, nestedClasses, enumOutput); - lines.push(dataClass); - lines.push(""); - } - - // Generate nested classes - for (const [, nestedCode] of nestedClasses) { - lines.push(nestedCode); - lines.push(""); - } - - // Generate enums - for (const enumCode of enumOutput) { - lines.push(enumCode); - } - - // Collect all serializable types (sorted alphabetically) - const serializableTypes: string[] = []; - - // Add SessionEvent base class - serializableTypes.push("SessionEvent"); - - // Add all event classes and their data classes - for (const variant of variants) { - serializableTypes.push(variant.className); - serializableTypes.push(variant.dataClassName); - } - - // Add all nested classes - for (const [className] of nestedClasses) { - serializableTypes.push(className); - } - - // Sort alphabetically - serializableTypes.sort((a, b) => a.localeCompare(b)); - - // Generate JsonSerializerContext with JsonSerializable attributes - lines.push(`[JsonSourceGenerationOptions(`); - lines.push(` JsonSerializerDefaults.Web,`); - lines.push(` AllowOutOfOrderMetadataProperties = true,`); - lines.push(` NumberHandling = JsonNumberHandling.AllowReadingFromString,`); - lines.push(` DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)]`); - for (const typeName of serializableTypes) { - lines.push(`[JsonSerializable(typeof(${typeName}))]`); - } - lines.push(`internal partial class SessionEventsJsonContext : JsonSerializerContext;`); - - return lines.join("\n"); -} diff --git a/nodejs/scripts/generate-session-types.ts b/nodejs/scripts/generate-session-types.ts deleted file mode 100644 index 8a0063a3..00000000 --- a/nodejs/scripts/generate-session-types.ts +++ /dev/null @@ -1,373 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - *--------------------------------------------------------------------------------------------*/ - -/** - * Generate session event types for all SDKs from the JSON schema - * - * This script reads the session-events.schema.json from the @github/copilot package - * (which should be npm linked from copilot-agent-runtime/dist-cli) and generates - * TypeScript, Python, Go, and C# type definitions for all SDKs. - * - * Workflow: - * 1. The schema is defined in copilot-agent-runtime using Zod schemas - * 2. copilot-agent-runtime/script/generate-session-types.ts generates the JSON schema - * 3. copilot-agent-runtime/esbuild.ts copies the schema to dist-cli/ - * 4. This script reads the schema from the linked @github/copilot package - * 5. Generates types for nodejs/src/generated/, python/copilot/generated/, go/generated/, and dotnet/src/Generated/ - * - * Usage: - * npm run generate:session-types - */ - -import { execFile } from "child_process"; -import fs from "fs/promises"; -import type { JSONSchema7, JSONSchema7Definition } from "json-schema"; -import { compile } from "json-schema-to-typescript"; -import path from "path"; -import { FetchingJSONSchemaStore, InputData, JSONSchemaInput, quicktype } from "quicktype-core"; -import { fileURLToPath } from "url"; -import { promisify } from "util"; -import { generateCSharpSessionTypes } from "./generate-csharp-session-types.js"; - -const execFileAsync = promisify(execFile); - -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); - -async function getSchemaPath(): Promise { - // Read from the @github/copilot package - const schemaPath = path.join( - __dirname, - "../node_modules/@github/copilot/schemas/session-events.schema.json" - ); - - try { - await fs.access(schemaPath); - console.log(`✅ Found schema at: ${schemaPath}`); - return schemaPath; - } catch (_error) { - throw new Error( - `Schema file not found at ${schemaPath}. ` + - `Make sure @github/copilot package is installed or linked.` - ); - } -} - -async function generateTypeScriptTypes(schemaPath: string) { - console.log("🔄 Generating TypeScript types from JSON Schema..."); - - const schema = JSON.parse(await fs.readFile(schemaPath, "utf-8")) as JSONSchema7; - const processedSchema = postProcessSchema(schema); - - const ts = await compile(processedSchema, "SessionEvent", { - bannerComment: `/** - * AUTO-GENERATED FILE - DO NOT EDIT - * - * Generated from: @github/copilot/session-events.schema.json - * Generated by: scripts/generate-session-types.ts - * Generated at: ${new Date().toISOString()} - * - * To update these types: - * 1. Update the schema in copilot-agent-runtime - * 2. Run: npm run generate:session-types - */`, - style: { - semi: true, - singleQuote: false, - trailingComma: "all", - }, - additionalProperties: false, // Stricter types - }); - - const outputPath = path.join(__dirname, "../src/generated/session-events.ts"); - await fs.mkdir(path.dirname(outputPath), { recursive: true }); - await fs.writeFile(outputPath, ts, "utf-8"); - - console.log(`✅ Generated TypeScript types: ${outputPath}`); -} - -/** - * Event types to exclude from generation (internal/legacy types) - */ -const EXCLUDED_EVENT_TYPES = new Set(["session.import_legacy"]); - -/** - * Post-process JSON Schema to make it compatible with quicktype - * Converts boolean const values to enum with single value - * Filters out excluded event types - */ -function postProcessSchema(schema: JSONSchema7): JSONSchema7 { - if (typeof schema !== "object" || schema === null) { - return schema; - } - - const processed: JSONSchema7 = { ...schema }; - - // Handle const with boolean values - convert to enum with single value - if ("const" in processed && typeof processed.const === "boolean") { - const constValue = processed.const; - delete processed.const; - processed.enum = [constValue]; - } - - // Recursively process all properties - if (processed.properties) { - const newProperties: Record = {}; - for (const [key, value] of Object.entries(processed.properties)) { - if (typeof value === "object" && value !== null) { - newProperties[key] = postProcessSchema(value as JSONSchema7); - } else { - newProperties[key] = value; - } - } - processed.properties = newProperties; - } - - // Process items (for arrays) - if (processed.items) { - if (typeof processed.items === "object" && !Array.isArray(processed.items)) { - processed.items = postProcessSchema(processed.items as JSONSchema7); - } else if (Array.isArray(processed.items)) { - processed.items = processed.items.map((item) => - typeof item === "object" ? postProcessSchema(item as JSONSchema7) : item - ) as JSONSchema7Definition[]; - } - } - - // Process anyOf, allOf, oneOf - also filter out excluded event types - for (const combiner of ["anyOf", "allOf", "oneOf"] as const) { - if (processed[combiner]) { - processed[combiner] = processed[combiner]!.filter((item) => { - if (typeof item !== "object") return true; - const typeConst = (item as JSONSchema7).properties?.type; - if (typeof typeConst === "object" && "const" in typeConst) { - return !EXCLUDED_EVENT_TYPES.has(typeConst.const as string); - } - return true; - }).map((item) => - typeof item === "object" ? postProcessSchema(item as JSONSchema7) : item - ) as JSONSchema7Definition[]; - } - } - - // Process definitions - if (processed.definitions) { - const newDefinitions: Record = {}; - for (const [key, value] of Object.entries(processed.definitions)) { - if (typeof value === "object" && value !== null) { - newDefinitions[key] = postProcessSchema(value as JSONSchema7); - } else { - newDefinitions[key] = value; - } - } - processed.definitions = newDefinitions; - } - - // Process additionalProperties if it's a schema - if (typeof processed.additionalProperties === "object") { - processed.additionalProperties = postProcessSchema( - processed.additionalProperties as JSONSchema7 - ); - } - - return processed; -} - -async function generatePythonTypes(schemaPath: string) { - console.log("🔄 Generating Python types from JSON Schema..."); - - const schemaContent = await fs.readFile(schemaPath, "utf-8"); - const schema = JSON.parse(schemaContent) as JSONSchema7; - - // Resolve the $ref at the root level and get the actual schema - const resolvedSchema = (schema.definitions?.SessionEvent as JSONSchema7) || schema; - - // Post-process to fix boolean const values - const processedSchema = postProcessSchema(resolvedSchema); - - const schemaInput = new JSONSchemaInput(new FetchingJSONSchemaStore()); - await schemaInput.addSource({ - name: "SessionEvent", - schema: JSON.stringify(processedSchema), - }); - - const inputData = new InputData(); - inputData.addInput(schemaInput); - - const result = await quicktype({ - inputData, - lang: "python", - rendererOptions: { - "python-version": "3.7", - }, - }); - - let generatedCode = result.lines.join("\n"); - - // Fix Python dataclass field ordering issue: - // Quicktype doesn't support default values in schemas, so it generates "arguments: Any" - // (without default) that comes after Optional fields (with defaults), violating Python's - // dataclass rules. We post-process to add "= None" to these unconstrained "Any" fields. - generatedCode = generatedCode.replace(/: Any$/gm, ": Any = None"); - - // Add UNKNOWN enum value and _missing_ handler for forward compatibility - // This ensures that new event types from the server don't cause errors - generatedCode = generatedCode.replace( - /^(class SessionEventType\(Enum\):.*?)(^\s*\n@dataclass)/ms, - `$1 # UNKNOWN is used for forward compatibility - new event types from the server - # will map to this value instead of raising an error - UNKNOWN = "unknown" - - @classmethod - def _missing_(cls, value: object) -> "SessionEventType": - """Handle unknown event types gracefully for forward compatibility.""" - return cls.UNKNOWN - -$2` - ); - - const banner = `""" -AUTO-GENERATED FILE - DO NOT EDIT - -Generated from: @github/copilot/session-events.schema.json -Generated by: scripts/generate-session-types.ts -Generated at: ${new Date().toISOString()} - -To update these types: -1. Update the schema in copilot-agent-runtime -2. Run: npm run generate:session-types -""" - -`; - - const outputPath = path.join(__dirname, "../../python/copilot/generated/session_events.py"); - await fs.mkdir(path.dirname(outputPath), { recursive: true }); - await fs.writeFile(outputPath, banner + generatedCode, "utf-8"); - - console.log(`✅ Generated Python types: ${outputPath}`); -} - -async function formatGoFile(filePath: string): Promise { - try { - await execFileAsync("go", ["fmt", filePath]); - console.log(`✅ Formatted Go file with go fmt: ${filePath}`); - } catch (error: unknown) { - if (error instanceof Error && "code" in error) { - if (error.code === "ENOENT") { - console.warn(`⚠️ go fmt not available - skipping formatting for ${filePath}`); - } else { - console.warn(`⚠️ go fmt failed for ${filePath}: ${error.message}`); - } - } - } -} - -async function generateGoTypes(schemaPath: string) { - console.log("🔄 Generating Go types from JSON Schema..."); - - const schemaContent = await fs.readFile(schemaPath, "utf-8"); - const schema = JSON.parse(schemaContent) as JSONSchema7; - - // Resolve the $ref at the root level and get the actual schema - const resolvedSchema = (schema.definitions?.SessionEvent as JSONSchema7) || schema; - - // Post-process to fix boolean const values - const processedSchema = postProcessSchema(resolvedSchema); - - const schemaInput = new JSONSchemaInput(new FetchingJSONSchemaStore()); - await schemaInput.addSource({ - name: "SessionEvent", - schema: JSON.stringify(processedSchema), - }); - - const inputData = new InputData(); - inputData.addInput(schemaInput); - - const result = await quicktype({ - inputData, - lang: "go", - rendererOptions: { - package: "copilot", - }, - }); - - const generatedCode = result.lines.join("\n"); - const banner = `// AUTO-GENERATED FILE - DO NOT EDIT -// -// Generated from: @github/copilot/session-events.schema.json -// Generated by: scripts/generate-session-types.ts -// Generated at: ${new Date().toISOString()} -// -// To update these types: -// 1. Update the schema in copilot-agent-runtime -// 2. Run: npm run generate:session-types - -`; - - const outputPath = path.join(__dirname, "../../go/generated_session_events.go"); - await fs.mkdir(path.dirname(outputPath), { recursive: true }); - await fs.writeFile(outputPath, banner + generatedCode, "utf-8"); - - console.log(`✅ Generated Go types: ${outputPath}`); - - await formatGoFile(outputPath); -} - -async function formatCSharpFile(filePath: string): Promise { - try { - // Get the directory containing the .csproj file - const projectDir = path.join(__dirname, "../../dotnet/src"); - const projectFile = path.join(projectDir, "GitHub.Copilot.SDK.csproj"); - - // dotnet format needs to be run from the project directory or with --workspace - await execFileAsync("dotnet", ["format", projectFile, "--include", filePath]); - console.log(`✅ Formatted C# file with dotnet format: ${filePath}`); - } catch (error: unknown) { - if (error instanceof Error && "code" in error) { - if ((error as NodeJS.ErrnoException).code === "ENOENT") { - console.warn( - `⚠️ dotnet format not available - skipping formatting for ${filePath}` - ); - } else { - console.warn( - `⚠️ dotnet format failed for ${filePath}: ${(error as Error).message}` - ); - } - } - } -} - -async function generateCSharpTypes(schemaPath: string) { - console.log("🔄 Generating C# types from JSON Schema..."); - - const schemaContent = await fs.readFile(schemaPath, "utf-8"); - const schema = JSON.parse(schemaContent) as JSONSchema7; - - const generatedAt = new Date().toISOString(); - const generatedCode = generateCSharpSessionTypes(schema, generatedAt); - - const outputPath = path.join(__dirname, "../../dotnet/src/Generated/SessionEvents.cs"); - await fs.mkdir(path.dirname(outputPath), { recursive: true }); - await fs.writeFile(outputPath, generatedCode, "utf-8"); - - console.log(`✅ Generated C# types: ${outputPath}`); - - await formatCSharpFile(outputPath); -} - -async function main() { - try { - const schemaPath = await getSchemaPath(); - await generateTypeScriptTypes(schemaPath); - await generatePythonTypes(schemaPath); - await generateGoTypes(schemaPath); - await generateCSharpTypes(schemaPath); - console.log("✅ Type generation complete!"); - } catch (error) { - console.error("❌ Type generation failed:", error); - process.exit(1); - } -} - -main(); diff --git a/nodejs/src/client.ts b/nodejs/src/client.ts index af6260c9..13b6eb3b 100644 --- a/nodejs/src/client.ts +++ b/nodejs/src/client.ts @@ -22,6 +22,7 @@ import { StreamMessageReader, StreamMessageWriter, } from "vscode-jsonrpc/node.js"; +import { createServerRpc } from "./generated/rpc.js"; import { getSdkProtocolVersion } from "./sdkProtocolVersion.js"; import { CopilotSession } from "./session.js"; import type { @@ -141,6 +142,21 @@ export class CopilotClient { SessionLifecycleEventType, Set<(event: SessionLifecycleEvent) => void> > = new Map(); + private _rpc: ReturnType | null = null; + + /** + * Typed server-scoped RPC methods. + * @throws Error if the client is not connected + */ + get rpc(): ReturnType { + if (!this.connection) { + throw new Error("Client is not connected. Call start() first."); + } + if (!this._rpc) { + this._rpc = createServerRpc(this.connection); + } + return this._rpc; + } /** * Creates a new CopilotClient instance. @@ -342,6 +358,7 @@ export class CopilotClient { ); } this.connection = null; + this._rpc = null; } // Clear models cache @@ -419,6 +436,7 @@ export class CopilotClient { // Ignore errors during force stop } this.connection = null; + this._rpc = null; } // Clear models cache diff --git a/nodejs/src/generated/rpc.ts b/nodejs/src/generated/rpc.ts new file mode 100644 index 00000000..4bd7de36 --- /dev/null +++ b/nodejs/src/generated/rpc.ts @@ -0,0 +1,208 @@ +/** + * AUTO-GENERATED FILE - DO NOT EDIT + * Generated from: api.schema.json + */ + +import type { MessageConnection } from "vscode-jsonrpc/node.js"; + +export interface PingResult { + /** + * Echoed message (or default greeting) + */ + message: string; + /** + * Server timestamp in milliseconds + */ + timestamp: number; + /** + * Server protocol version number + */ + protocolVersion: number; +} + +export interface PingParams { + /** + * Optional message to echo back + */ + message?: string; +} + +export interface ModelsListResult { + /** + * List of available models with full metadata + */ + models: { + /** + * Model identifier (e.g., "claude-sonnet-4.5") + */ + id: string; + /** + * Display name + */ + name: string; + /** + * Model capabilities and limits + */ + capabilities: { + supports: { + vision: boolean; + /** + * Whether this model supports reasoning effort configuration + */ + reasoningEffort: boolean; + }; + limits: { + max_prompt_tokens?: number; + max_output_tokens?: number; + max_context_window_tokens: number; + }; + }; + /** + * Policy state (if applicable) + */ + policy?: { + state: string; + terms: string; + }; + /** + * Billing information + */ + billing?: { + multiplier: number; + }; + /** + * Supported reasoning effort levels (only present if model supports reasoning effort) + */ + supportedReasoningEfforts?: string[]; + /** + * Default reasoning effort level (only present if model supports reasoning effort) + */ + defaultReasoningEffort?: string; + }[]; +} + +export interface ToolsListResult { + /** + * List of available built-in tools with metadata + */ + tools: { + /** + * Tool identifier (e.g., "bash", "grep", "str_replace_editor") + */ + name: string; + /** + * Optional namespaced name for declarative filtering (e.g., "playwright/navigate" for MCP tools) + */ + namespacedName?: string; + /** + * Description of what the tool does + */ + description: string; + /** + * JSON Schema for the tool's input parameters + */ + parameters?: { + [k: string]: unknown; + }; + /** + * Optional instructions for how to use this tool effectively + */ + instructions?: string; + }[]; +} + +export interface ToolsListParams { + /** + * Optional model ID — when provided, the returned tool list reflects model-specific overrides + */ + model?: string; +} + +export interface AccountGetQuotaResult { + /** + * Quota snapshots keyed by type (e.g., chat, completions, premium_interactions) + */ + quotaSnapshots: { + [k: string]: { + /** + * Number of requests included in the entitlement + */ + entitlementRequests: number; + /** + * Number of requests used so far this period + */ + usedRequests: number; + /** + * Percentage of entitlement remaining + */ + remainingPercentage: number; + /** + * Number of overage requests made this period + */ + overage: number; + /** + * Whether pay-per-request usage is allowed when quota is exhausted + */ + overageAllowedWithExhaustedQuota: boolean; + /** + * Date when the quota resets (ISO 8601) + */ + resetDate?: string; + }; + }; +} + +export interface SessionModelGetCurrentResult { + modelId?: string; +} + +export interface SessionModelGetCurrentParams { + /** + * Target session identifier + */ + sessionId: string; +} + +export interface SessionModelSwitchToResult { + modelId?: string; +} + +export interface SessionModelSwitchToParams { + /** + * Target session identifier + */ + sessionId: string; + modelId: string; +} + +/** Create typed server-scoped RPC methods (no session required). */ +export function createServerRpc(connection: MessageConnection) { + return { + ping: async (params: PingParams): Promise => + connection.sendRequest("ping", params), + models: { + list: async (): Promise => + connection.sendRequest("models.list", {}), + }, + tools: { + list: async (params: ToolsListParams): Promise => + connection.sendRequest("tools.list", params), + }, + account: { + getQuota: async (): Promise => + connection.sendRequest("account.getQuota", {}), + }, + }; +} + +/** Create typed session-scoped RPC methods. */ +export function createSessionRpc(connection: MessageConnection, sessionId: string) { + return { + model: { + getCurrent: async (): Promise => + connection.sendRequest("session.model.getCurrent", { sessionId }), + switchTo: async (params: Omit): Promise => + connection.sendRequest("session.model.switchTo", { sessionId, ...params }), + }, + }; +} diff --git a/nodejs/src/generated/session-events.ts b/nodejs/src/generated/session-events.ts index 86783a04..6f4177c4 100644 --- a/nodejs/src/generated/session-events.ts +++ b/nodejs/src/generated/session-events.ts @@ -1,13 +1,6 @@ /** * AUTO-GENERATED FILE - DO NOT EDIT - * - * Generated from: @github/copilot/session-events.schema.json - * Generated by: scripts/generate-session-types.ts - * Generated at: 2026-02-06T20:38:23.139Z - * - * To update these types: - * 1. Update the schema in copilot-agent-runtime - * 2. Run: npm run generate:session-types + * Generated from: session-events.schema.json */ export type SessionEvent = diff --git a/nodejs/src/session.ts b/nodejs/src/session.ts index 19da9bd1..04525d2b 100644 --- a/nodejs/src/session.ts +++ b/nodejs/src/session.ts @@ -8,6 +8,7 @@ */ import type { MessageConnection } from "vscode-jsonrpc/node"; +import { createSessionRpc } from "./generated/rpc.js"; import type { MessageOptions, PermissionHandler, @@ -62,6 +63,7 @@ export class CopilotSession { private permissionHandler?: PermissionHandler; private userInputHandler?: UserInputHandler; private hooks?: SessionHooks; + private _rpc: ReturnType | null = null; /** * Creates a new CopilotSession instance. @@ -77,6 +79,16 @@ export class CopilotSession { private readonly _workspacePath?: string ) {} + /** + * Typed session-scoped RPC methods. + */ + get rpc(): ReturnType { + if (!this._rpc) { + this._rpc = createSessionRpc(this.connection, this.sessionId); + } + return this._rpc; + } + /** * Path to the session workspace directory when infinite sessions are enabled. * Contains checkpoints/, plan.md, and files/ subdirectories. diff --git a/nodejs/test/e2e/rpc.test.ts b/nodejs/test/e2e/rpc.test.ts new file mode 100644 index 00000000..99af862f --- /dev/null +++ b/nodejs/test/e2e/rpc.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it, onTestFinished } from "vitest"; +import { CopilotClient } from "../../src/index.js"; +import { createSdkTestContext } from "./harness/sdkTestContext.js"; + +function onTestFinishedForceStop(client: CopilotClient) { + onTestFinished(async () => { + try { + await client.forceStop(); + } catch { + // Ignore cleanup errors - process may already be stopped + } + }); +} + +describe("RPC", () => { + it("should call rpc.ping with typed params and result", async () => { + const client = new CopilotClient({ useStdio: true }); + onTestFinishedForceStop(client); + + await client.start(); + + const result = await client.rpc.ping({ message: "typed rpc test" }); + expect(result.message).toBe("pong: typed rpc test"); + expect(typeof result.timestamp).toBe("number"); + + await client.stop(); + }); + + it("should call rpc.models.list with typed result", async () => { + const client = new CopilotClient({ useStdio: true }); + onTestFinishedForceStop(client); + + await client.start(); + + const authStatus = await client.getAuthStatus(); + if (!authStatus.isAuthenticated) { + await client.stop(); + return; + } + + const result = await client.rpc.models.list(); + expect(result.models).toBeDefined(); + expect(Array.isArray(result.models)).toBe(true); + + await client.stop(); + }); + + // account.getQuota is defined in schema but not yet implemented in CLI + it.skip("should call rpc.account.getQuota when authenticated", async () => { + const client = new CopilotClient({ useStdio: true }); + onTestFinishedForceStop(client); + + await client.start(); + + const authStatus = await client.getAuthStatus(); + if (!authStatus.isAuthenticated) { + await client.stop(); + return; + } + + const result = await client.rpc.account.getQuota(); + expect(result.quotaSnapshots).toBeDefined(); + expect(typeof result.quotaSnapshots).toBe("object"); + + await client.stop(); + }); +}); + +describe("Session RPC", async () => { + const { copilotClient: client } = await createSdkTestContext(); + + // session.model.getCurrent is defined in schema but not yet implemented in CLI + it.skip("should call session.rpc.model.getCurrent", async () => { + const session = await client.createSession({ model: "claude-sonnet-4.5" }); + + const result = await session.rpc.model.getCurrent(); + expect(result.modelId).toBeDefined(); + expect(typeof result.modelId).toBe("string"); + }); + + // session.model.switchTo is defined in schema but not yet implemented in CLI + it.skip("should call session.rpc.model.switchTo", async () => { + const session = await client.createSession({ model: "claude-sonnet-4.5" }); + + // Get initial model + const before = await session.rpc.model.getCurrent(); + expect(before.modelId).toBeDefined(); + + // Switch to a different model + const result = await session.rpc.model.switchTo({ modelId: "gpt-4.1" }); + expect(result.modelId).toBe("gpt-4.1"); + + // Verify the switch persisted + const after = await session.rpc.model.getCurrent(); + expect(after.modelId).toBe("gpt-4.1"); + }); +}); diff --git a/python/copilot/client.py b/python/copilot/client.py index 85b72897..0f4ca023 100644 --- a/python/copilot/client.py +++ b/python/copilot/client.py @@ -23,6 +23,7 @@ from pathlib import Path from typing import Any, Callable, Optional, cast +from .generated.rpc import ServerRpc from .generated.session_events import session_event_from_dict from .jsonrpc import JsonRpcClient from .sdk_protocol_version import get_sdk_protocol_version @@ -202,6 +203,14 @@ def __init__(self, options: Optional[CopilotClientOptions] = None): SessionLifecycleEventType, list[SessionLifecycleHandler] ] = {} self._lifecycle_handlers_lock = threading.Lock() + self._rpc: Optional[ServerRpc] = None + + @property + def rpc(self) -> ServerRpc: + """Typed server-scoped RPC methods.""" + if self._rpc is None: + raise RuntimeError("Client is not connected. Call start() first.") + return self._rpc def _parse_cli_url(self, url: str) -> tuple[str, int]: """ @@ -325,6 +334,7 @@ async def stop(self) -> list["StopError"]: if self._client: await self._client.stop() self._client = None + self._rpc = None # Clear models cache async with self._models_cache_lock: @@ -373,6 +383,7 @@ async def force_stop(self) -> None: except Exception: pass # Ignore errors during force stop self._client = None + self._rpc = None # Clear models cache async with self._models_cache_lock: @@ -1222,6 +1233,7 @@ async def _connect_via_stdio(self) -> None: # Create JSON-RPC client with the process self._client = JsonRpcClient(self._process) + self._rpc = ServerRpc(self._client) # Set up notification handler for session events # Note: This handler is called from the event loop (thread-safe scheduling) @@ -1304,6 +1316,7 @@ def wait(self, timeout=None): self._process = SocketWrapper(sock_file, sock) # type: ignore self._client = JsonRpcClient(self._process) + self._rpc = ServerRpc(self._client) # Set up notification handler for session events def handle_notification(method: str, params: dict): diff --git a/python/copilot/generated/rpc.py b/python/copilot/generated/rpc.py new file mode 100644 index 00000000..14a1ec7c --- /dev/null +++ b/python/copilot/generated/rpc.py @@ -0,0 +1,604 @@ +""" +AUTO-GENERATED FILE - DO NOT EDIT +Generated from: api.schema.json +""" + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from ..jsonrpc import JsonRpcClient + + +from dataclasses import dataclass +from typing import Any, Optional, List, Dict, TypeVar, Type, cast, Callable + + +T = TypeVar("T") + + +def from_str(x: Any) -> str: + assert isinstance(x, str) + return x + + +def from_float(x: Any) -> float: + assert isinstance(x, (float, int)) and not isinstance(x, bool) + return float(x) + + +def to_float(x: Any) -> float: + assert isinstance(x, (int, float)) + return x + + +def from_none(x: Any) -> Any: + assert x is None + return x + + +def from_union(fs, x): + for f in fs: + try: + return f(x) + except Exception: + pass + assert False + + +def from_bool(x: Any) -> bool: + assert isinstance(x, bool) + return x + + +def to_class(c: Type[T], x: Any) -> dict: + assert isinstance(x, c) + return cast(Any, x).to_dict() + + +def from_list(f: Callable[[Any], T], x: Any) -> List[T]: + assert isinstance(x, list) + return [f(y) for y in x] + + +def from_dict(f: Callable[[Any], T], x: Any) -> Dict[str, T]: + assert isinstance(x, dict) + return { k: f(v) for (k, v) in x.items() } + + +@dataclass +class PingResult: + message: str + """Echoed message (or default greeting)""" + + protocol_version: float + """Server protocol version number""" + + timestamp: float + """Server timestamp in milliseconds""" + + @staticmethod + def from_dict(obj: Any) -> 'PingResult': + assert isinstance(obj, dict) + message = from_str(obj.get("message")) + protocol_version = from_float(obj.get("protocolVersion")) + timestamp = from_float(obj.get("timestamp")) + return PingResult(message, protocol_version, timestamp) + + def to_dict(self) -> dict: + result: dict = {} + result["message"] = from_str(self.message) + result["protocolVersion"] = to_float(self.protocol_version) + result["timestamp"] = to_float(self.timestamp) + return result + + +@dataclass +class PingParams: + message: Optional[str] = None + """Optional message to echo back""" + + @staticmethod + def from_dict(obj: Any) -> 'PingParams': + assert isinstance(obj, dict) + message = from_union([from_str, from_none], obj.get("message")) + return PingParams(message) + + def to_dict(self) -> dict: + result: dict = {} + if self.message is not None: + result["message"] = from_union([from_str, from_none], self.message) + return result + + +@dataclass +class Billing: + """Billing information""" + + multiplier: float + + @staticmethod + def from_dict(obj: Any) -> 'Billing': + assert isinstance(obj, dict) + multiplier = from_float(obj.get("multiplier")) + return Billing(multiplier) + + def to_dict(self) -> dict: + result: dict = {} + result["multiplier"] = to_float(self.multiplier) + return result + + +@dataclass +class Limits: + max_context_window_tokens: float + max_output_tokens: Optional[float] = None + max_prompt_tokens: Optional[float] = None + + @staticmethod + def from_dict(obj: Any) -> 'Limits': + assert isinstance(obj, dict) + max_context_window_tokens = from_float(obj.get("max_context_window_tokens")) + max_output_tokens = from_union([from_float, from_none], obj.get("max_output_tokens")) + max_prompt_tokens = from_union([from_float, from_none], obj.get("max_prompt_tokens")) + return Limits(max_context_window_tokens, max_output_tokens, max_prompt_tokens) + + def to_dict(self) -> dict: + result: dict = {} + result["max_context_window_tokens"] = to_float(self.max_context_window_tokens) + if self.max_output_tokens is not None: + result["max_output_tokens"] = from_union([to_float, from_none], self.max_output_tokens) + if self.max_prompt_tokens is not None: + result["max_prompt_tokens"] = from_union([to_float, from_none], self.max_prompt_tokens) + return result + + +@dataclass +class Supports: + reasoning_effort: bool + """Whether this model supports reasoning effort configuration""" + + vision: bool + + @staticmethod + def from_dict(obj: Any) -> 'Supports': + assert isinstance(obj, dict) + reasoning_effort = from_bool(obj.get("reasoningEffort")) + vision = from_bool(obj.get("vision")) + return Supports(reasoning_effort, vision) + + def to_dict(self) -> dict: + result: dict = {} + result["reasoningEffort"] = from_bool(self.reasoning_effort) + result["vision"] = from_bool(self.vision) + return result + + +@dataclass +class Capabilities: + """Model capabilities and limits""" + + limits: Limits + supports: Supports + + @staticmethod + def from_dict(obj: Any) -> 'Capabilities': + assert isinstance(obj, dict) + limits = Limits.from_dict(obj.get("limits")) + supports = Supports.from_dict(obj.get("supports")) + return Capabilities(limits, supports) + + def to_dict(self) -> dict: + result: dict = {} + result["limits"] = to_class(Limits, self.limits) + result["supports"] = to_class(Supports, self.supports) + return result + + +@dataclass +class Policy: + """Policy state (if applicable)""" + + state: str + terms: str + + @staticmethod + def from_dict(obj: Any) -> 'Policy': + assert isinstance(obj, dict) + state = from_str(obj.get("state")) + terms = from_str(obj.get("terms")) + return Policy(state, terms) + + def to_dict(self) -> dict: + result: dict = {} + result["state"] = from_str(self.state) + result["terms"] = from_str(self.terms) + return result + + +@dataclass +class Model: + capabilities: Capabilities + """Model capabilities and limits""" + + id: str + """Model identifier (e.g., "claude-sonnet-4.5")""" + + name: str + """Display name""" + + billing: Optional[Billing] = None + """Billing information""" + + default_reasoning_effort: Optional[str] = None + """Default reasoning effort level (only present if model supports reasoning effort)""" + + policy: Optional[Policy] = None + """Policy state (if applicable)""" + + supported_reasoning_efforts: Optional[List[str]] = None + """Supported reasoning effort levels (only present if model supports reasoning effort)""" + + @staticmethod + def from_dict(obj: Any) -> 'Model': + assert isinstance(obj, dict) + capabilities = Capabilities.from_dict(obj.get("capabilities")) + id = from_str(obj.get("id")) + name = from_str(obj.get("name")) + billing = from_union([Billing.from_dict, from_none], obj.get("billing")) + default_reasoning_effort = from_union([from_str, from_none], obj.get("defaultReasoningEffort")) + policy = from_union([Policy.from_dict, from_none], obj.get("policy")) + supported_reasoning_efforts = from_union([lambda x: from_list(from_str, x), from_none], obj.get("supportedReasoningEfforts")) + return Model(capabilities, id, name, billing, default_reasoning_effort, policy, supported_reasoning_efforts) + + def to_dict(self) -> dict: + result: dict = {} + result["capabilities"] = to_class(Capabilities, self.capabilities) + result["id"] = from_str(self.id) + result["name"] = from_str(self.name) + if self.billing is not None: + result["billing"] = from_union([lambda x: to_class(Billing, x), from_none], self.billing) + if self.default_reasoning_effort is not None: + result["defaultReasoningEffort"] = from_union([from_str, from_none], self.default_reasoning_effort) + if self.policy is not None: + result["policy"] = from_union([lambda x: to_class(Policy, x), from_none], self.policy) + if self.supported_reasoning_efforts is not None: + result["supportedReasoningEfforts"] = from_union([lambda x: from_list(from_str, x), from_none], self.supported_reasoning_efforts) + return result + + +@dataclass +class ModelsListResult: + models: List[Model] + """List of available models with full metadata""" + + @staticmethod + def from_dict(obj: Any) -> 'ModelsListResult': + assert isinstance(obj, dict) + models = from_list(Model.from_dict, obj.get("models")) + return ModelsListResult(models) + + def to_dict(self) -> dict: + result: dict = {} + result["models"] = from_list(lambda x: to_class(Model, x), self.models) + return result + + +@dataclass +class Tool: + description: str + """Description of what the tool does""" + + name: str + """Tool identifier (e.g., "bash", "grep", "str_replace_editor")""" + + instructions: Optional[str] = None + """Optional instructions for how to use this tool effectively""" + + namespaced_name: Optional[str] = None + """Optional namespaced name for declarative filtering (e.g., "playwright/navigate" for MCP + tools) + """ + parameters: Optional[Dict[str, Any]] = None + """JSON Schema for the tool's input parameters""" + + @staticmethod + def from_dict(obj: Any) -> 'Tool': + assert isinstance(obj, dict) + description = from_str(obj.get("description")) + name = from_str(obj.get("name")) + instructions = from_union([from_str, from_none], obj.get("instructions")) + namespaced_name = from_union([from_str, from_none], obj.get("namespacedName")) + parameters = from_union([lambda x: from_dict(lambda x: x, x), from_none], obj.get("parameters")) + return Tool(description, name, instructions, namespaced_name, parameters) + + def to_dict(self) -> dict: + result: dict = {} + result["description"] = from_str(self.description) + result["name"] = from_str(self.name) + if self.instructions is not None: + result["instructions"] = from_union([from_str, from_none], self.instructions) + if self.namespaced_name is not None: + result["namespacedName"] = from_union([from_str, from_none], self.namespaced_name) + if self.parameters is not None: + result["parameters"] = from_union([lambda x: from_dict(lambda x: x, x), from_none], self.parameters) + return result + + +@dataclass +class ToolsListResult: + tools: List[Tool] + """List of available built-in tools with metadata""" + + @staticmethod + def from_dict(obj: Any) -> 'ToolsListResult': + assert isinstance(obj, dict) + tools = from_list(Tool.from_dict, obj.get("tools")) + return ToolsListResult(tools) + + def to_dict(self) -> dict: + result: dict = {} + result["tools"] = from_list(lambda x: to_class(Tool, x), self.tools) + return result + + +@dataclass +class ToolsListParams: + model: Optional[str] = None + """Optional model ID — when provided, the returned tool list reflects model-specific + overrides + """ + + @staticmethod + def from_dict(obj: Any) -> 'ToolsListParams': + assert isinstance(obj, dict) + model = from_union([from_str, from_none], obj.get("model")) + return ToolsListParams(model) + + def to_dict(self) -> dict: + result: dict = {} + if self.model is not None: + result["model"] = from_union([from_str, from_none], self.model) + return result + + +@dataclass +class QuotaSnapshot: + entitlement_requests: float + """Number of requests included in the entitlement""" + + overage: float + """Number of overage requests made this period""" + + overage_allowed_with_exhausted_quota: bool + """Whether pay-per-request usage is allowed when quota is exhausted""" + + remaining_percentage: float + """Percentage of entitlement remaining""" + + used_requests: float + """Number of requests used so far this period""" + + reset_date: Optional[str] = None + """Date when the quota resets (ISO 8601)""" + + @staticmethod + def from_dict(obj: Any) -> 'QuotaSnapshot': + assert isinstance(obj, dict) + entitlement_requests = from_float(obj.get("entitlementRequests")) + overage = from_float(obj.get("overage")) + overage_allowed_with_exhausted_quota = from_bool(obj.get("overageAllowedWithExhaustedQuota")) + remaining_percentage = from_float(obj.get("remainingPercentage")) + used_requests = from_float(obj.get("usedRequests")) + reset_date = from_union([from_str, from_none], obj.get("resetDate")) + return QuotaSnapshot(entitlement_requests, overage, overage_allowed_with_exhausted_quota, remaining_percentage, used_requests, reset_date) + + def to_dict(self) -> dict: + result: dict = {} + result["entitlementRequests"] = to_float(self.entitlement_requests) + result["overage"] = to_float(self.overage) + result["overageAllowedWithExhaustedQuota"] = from_bool(self.overage_allowed_with_exhausted_quota) + result["remainingPercentage"] = to_float(self.remaining_percentage) + result["usedRequests"] = to_float(self.used_requests) + if self.reset_date is not None: + result["resetDate"] = from_union([from_str, from_none], self.reset_date) + return result + + +@dataclass +class AccountGetQuotaResult: + quota_snapshots: Dict[str, QuotaSnapshot] + """Quota snapshots keyed by type (e.g., chat, completions, premium_interactions)""" + + @staticmethod + def from_dict(obj: Any) -> 'AccountGetQuotaResult': + assert isinstance(obj, dict) + quota_snapshots = from_dict(QuotaSnapshot.from_dict, obj.get("quotaSnapshots")) + return AccountGetQuotaResult(quota_snapshots) + + def to_dict(self) -> dict: + result: dict = {} + result["quotaSnapshots"] = from_dict(lambda x: to_class(QuotaSnapshot, x), self.quota_snapshots) + return result + + +@dataclass +class SessionModelGetCurrentResult: + model_id: Optional[str] = None + + @staticmethod + def from_dict(obj: Any) -> 'SessionModelGetCurrentResult': + assert isinstance(obj, dict) + model_id = from_union([from_str, from_none], obj.get("modelId")) + return SessionModelGetCurrentResult(model_id) + + def to_dict(self) -> dict: + result: dict = {} + if self.model_id is not None: + result["modelId"] = from_union([from_str, from_none], self.model_id) + return result + + +@dataclass +class SessionModelSwitchToResult: + model_id: Optional[str] = None + + @staticmethod + def from_dict(obj: Any) -> 'SessionModelSwitchToResult': + assert isinstance(obj, dict) + model_id = from_union([from_str, from_none], obj.get("modelId")) + return SessionModelSwitchToResult(model_id) + + def to_dict(self) -> dict: + result: dict = {} + if self.model_id is not None: + result["modelId"] = from_union([from_str, from_none], self.model_id) + return result + + +@dataclass +class SessionModelSwitchToParams: + model_id: str + + @staticmethod + def from_dict(obj: Any) -> 'SessionModelSwitchToParams': + assert isinstance(obj, dict) + model_id = from_str(obj.get("modelId")) + return SessionModelSwitchToParams(model_id) + + def to_dict(self) -> dict: + result: dict = {} + result["modelId"] = from_str(self.model_id) + return result + + +def ping_result_from_dict(s: Any) -> PingResult: + return PingResult.from_dict(s) + + +def ping_result_to_dict(x: PingResult) -> Any: + return to_class(PingResult, x) + + +def ping_params_from_dict(s: Any) -> PingParams: + return PingParams.from_dict(s) + + +def ping_params_to_dict(x: PingParams) -> Any: + return to_class(PingParams, x) + + +def models_list_result_from_dict(s: Any) -> ModelsListResult: + return ModelsListResult.from_dict(s) + + +def models_list_result_to_dict(x: ModelsListResult) -> Any: + return to_class(ModelsListResult, x) + + +def tools_list_result_from_dict(s: Any) -> ToolsListResult: + return ToolsListResult.from_dict(s) + + +def tools_list_result_to_dict(x: ToolsListResult) -> Any: + return to_class(ToolsListResult, x) + + +def tools_list_params_from_dict(s: Any) -> ToolsListParams: + return ToolsListParams.from_dict(s) + + +def tools_list_params_to_dict(x: ToolsListParams) -> Any: + return to_class(ToolsListParams, x) + + +def account_get_quota_result_from_dict(s: Any) -> AccountGetQuotaResult: + return AccountGetQuotaResult.from_dict(s) + + +def account_get_quota_result_to_dict(x: AccountGetQuotaResult) -> Any: + return to_class(AccountGetQuotaResult, x) + + +def session_model_get_current_result_from_dict(s: Any) -> SessionModelGetCurrentResult: + return SessionModelGetCurrentResult.from_dict(s) + + +def session_model_get_current_result_to_dict(x: SessionModelGetCurrentResult) -> Any: + return to_class(SessionModelGetCurrentResult, x) + + +def session_model_switch_to_result_from_dict(s: Any) -> SessionModelSwitchToResult: + return SessionModelSwitchToResult.from_dict(s) + + +def session_model_switch_to_result_to_dict(x: SessionModelSwitchToResult) -> Any: + return to_class(SessionModelSwitchToResult, x) + + +def session_model_switch_to_params_from_dict(s: Any) -> SessionModelSwitchToParams: + return SessionModelSwitchToParams.from_dict(s) + + +def session_model_switch_to_params_to_dict(x: SessionModelSwitchToParams) -> Any: + return to_class(SessionModelSwitchToParams, x) + + +class ModelsApi: + def __init__(self, client: "JsonRpcClient"): + self._client = client + + async def list(self) -> ModelsListResult: + return ModelsListResult.from_dict(await self._client.request("models.list", {})) + + +class ToolsApi: + def __init__(self, client: "JsonRpcClient"): + self._client = client + + async def list(self, params: ToolsListParams) -> ToolsListResult: + params_dict = {k: v for k, v in params.to_dict().items() if v is not None} + return ToolsListResult.from_dict(await self._client.request("tools.list", params_dict)) + + +class AccountApi: + def __init__(self, client: "JsonRpcClient"): + self._client = client + + async def get_quota(self) -> AccountGetQuotaResult: + return AccountGetQuotaResult.from_dict(await self._client.request("account.getQuota", {})) + + +class ServerRpc: + """Typed server-scoped RPC methods.""" + def __init__(self, client: "JsonRpcClient"): + self._client = client + self.models = ModelsApi(client) + self.tools = ToolsApi(client) + self.account = AccountApi(client) + + async def ping(self, params: PingParams) -> PingResult: + params_dict = {k: v for k, v in params.to_dict().items() if v is not None} + return PingResult.from_dict(await self._client.request("ping", params_dict)) + + +class ModelApi: + def __init__(self, client: "JsonRpcClient", session_id: str): + self._client = client + self._session_id = session_id + + async def get_current(self) -> SessionModelGetCurrentResult: + return SessionModelGetCurrentResult.from_dict(await self._client.request("session.model.getCurrent", {"sessionId": self._session_id})) + + async def switch_to(self, params: SessionModelSwitchToParams) -> SessionModelSwitchToResult: + params_dict = {k: v for k, v in params.to_dict().items() if v is not None} + params_dict["sessionId"] = self._session_id + return SessionModelSwitchToResult.from_dict(await self._client.request("session.model.switchTo", params_dict)) + + +class SessionRpc: + """Typed session-scoped RPC methods.""" + def __init__(self, client: "JsonRpcClient", session_id: str): + self._client = client + self._session_id = session_id + self.model = ModelApi(client, session_id) + diff --git a/python/copilot/generated/session_events.py b/python/copilot/generated/session_events.py index 84dff82e..26ef6f42 100644 --- a/python/copilot/generated/session_events.py +++ b/python/copilot/generated/session_events.py @@ -1,13 +1,6 @@ """ AUTO-GENERATED FILE - DO NOT EDIT - -Generated from: @github/copilot/session-events.schema.json -Generated by: scripts/generate-session-types.ts -Generated at: 2026-02-06T20:38:23.376Z - -To update these types: -1. Update the schema in copilot-agent-runtime -2. Run: npm run generate:session-types +Generated from: session-events.schema.json """ from dataclasses import dataclass @@ -51,7 +44,7 @@ def from_union(fs, x): for f in fs: try: return f(x) - except: + except Exception: pass assert False @@ -954,8 +947,7 @@ class SessionEventType(Enum): TOOL_EXECUTION_START = "tool.execution_start" TOOL_USER_REQUESTED = "tool.user_requested" USER_MESSAGE = "user.message" - # UNKNOWN is used for forward compatibility - new event types from the server - # will map to this value instead of raising an error + # UNKNOWN is used for forward compatibility UNKNOWN = "unknown" @classmethod diff --git a/python/copilot/session.py b/python/copilot/session.py index fb39e9fc..d7bd1a3f 100644 --- a/python/copilot/session.py +++ b/python/copilot/session.py @@ -10,6 +10,7 @@ import threading from typing import Any, Callable, Optional +from .generated.rpc import SessionRpc from .generated.session_events import SessionEvent, SessionEventType, session_event_from_dict from .types import ( MessageOptions, @@ -79,6 +80,14 @@ def __init__(self, session_id: str, client: Any, workspace_path: Optional[str] = self._user_input_handler_lock = threading.Lock() self._hooks: Optional[SessionHooks] = None self._hooks_lock = threading.Lock() + self._rpc: Optional[SessionRpc] = None + + @property + def rpc(self) -> SessionRpc: + """Typed session-scoped RPC methods.""" + if self._rpc is None: + self._rpc = SessionRpc(self._client, self.session_id) + return self._rpc @property def workspace_path(self) -> Optional[str]: diff --git a/python/e2e/test_rpc.py b/python/e2e/test_rpc.py new file mode 100644 index 00000000..bc598a69 --- /dev/null +++ b/python/e2e/test_rpc.py @@ -0,0 +1,104 @@ +"""E2E RPC Tests""" + +import pytest + +from copilot import CopilotClient +from copilot.generated.rpc import PingParams + +from .testharness import CLI_PATH, E2ETestContext + +pytestmark = pytest.mark.asyncio(loop_scope="module") + + +class TestRpc: + @pytest.mark.asyncio + async def test_should_call_rpc_ping_with_typed_params(self): + """Test calling rpc.ping with typed params and result""" + client = CopilotClient({"cli_path": CLI_PATH, "use_stdio": True}) + + try: + await client.start() + + result = await client.rpc.ping(PingParams(message="typed rpc test")) + assert result.message == "pong: typed rpc test" + assert isinstance(result.timestamp, (int, float)) + + await client.stop() + finally: + await client.force_stop() + + @pytest.mark.asyncio + async def test_should_call_rpc_models_list(self): + """Test calling rpc.models.list with typed result""" + client = CopilotClient({"cli_path": CLI_PATH, "use_stdio": True}) + + try: + await client.start() + + auth_status = await client.get_auth_status() + if not auth_status.isAuthenticated: + await client.stop() + return + + result = await client.rpc.models.list() + assert result.models is not None + assert isinstance(result.models, list) + + await client.stop() + finally: + await client.force_stop() + + # account.getQuota is defined in schema but not yet implemented in CLI + @pytest.mark.skip(reason="account.getQuota not yet implemented in CLI") + @pytest.mark.asyncio + async def test_should_call_rpc_account_get_quota(self): + """Test calling rpc.account.getQuota when authenticated""" + client = CopilotClient({"cli_path": CLI_PATH, "use_stdio": True}) + + try: + await client.start() + + auth_status = await client.get_auth_status() + if not auth_status.isAuthenticated: + await client.stop() + return + + result = await client.rpc.account.get_quota() + assert result.quota_snapshots is not None + assert isinstance(result.quota_snapshots, dict) + + await client.stop() + finally: + await client.force_stop() + + +class TestSessionRpc: + # session.model.getCurrent is defined in schema but not yet implemented in CLI + @pytest.mark.skip(reason="session.model.getCurrent not yet implemented in CLI") + async def test_should_call_session_rpc_model_get_current(self, ctx: E2ETestContext): + """Test calling session.rpc.model.getCurrent""" + session = await ctx.client.create_session({"model": "claude-sonnet-4.5"}) + + result = await session.rpc.model.get_current() + assert result.model_id is not None + assert isinstance(result.model_id, str) + + # session.model.switchTo is defined in schema but not yet implemented in CLI + @pytest.mark.skip(reason="session.model.switchTo not yet implemented in CLI") + async def test_should_call_session_rpc_model_switch_to(self, ctx: E2ETestContext): + """Test calling session.rpc.model.switchTo""" + from copilot.generated.rpc import SessionModelSwitchToParams + + session = await ctx.client.create_session({"model": "claude-sonnet-4.5"}) + + # Get initial model + before = await session.rpc.model.get_current() + assert before.model_id is not None + + # Switch to a different model + result = await session.rpc.model.switch_to(SessionModelSwitchToParams(model_id="gpt-4.1")) + assert result.model_id == "gpt-4.1" + + # Verify the switch persisted + after = await session.rpc.model.get_current() + assert after.model_id == "gpt-4.1" diff --git a/scripts/codegen/.gitignore b/scripts/codegen/.gitignore new file mode 100644 index 00000000..c2658d7d --- /dev/null +++ b/scripts/codegen/.gitignore @@ -0,0 +1 @@ +node_modules/ diff --git a/scripts/codegen/csharp.ts b/scripts/codegen/csharp.ts new file mode 100644 index 00000000..bae52c55 --- /dev/null +++ b/scripts/codegen/csharp.ts @@ -0,0 +1,772 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +/** + * C# code generator for session-events and RPC types. + */ + +import { execFile } from "child_process"; +import fs from "fs/promises"; +import path from "path"; +import { promisify } from "util"; +import type { JSONSchema7 } from "json-schema"; +import { + getSessionEventsSchemaPath, + getApiSchemaPath, + writeGeneratedFile, + isRpcMethod, + EXCLUDED_EVENT_TYPES, + REPO_ROOT, + type ApiSchema, + type RpcMethod, +} from "./utils.js"; + +const execFileAsync = promisify(execFile); + +// ── C# utilities ──────────────────────────────────────────────────────────── + +function toPascalCase(name: string): string { + if (name.includes("_")) { + return name.split("_").map((p) => p.charAt(0).toUpperCase() + p.slice(1)).join(""); + } + return name.charAt(0).toUpperCase() + name.slice(1); +} + +function typeToClassName(typeName: string): string { + return typeName.split(/[._]/).map((p) => p.charAt(0).toUpperCase() + p.slice(1)).join(""); +} + +function toPascalCaseEnumMember(value: string): string { + return value.split(/[-_.]/).map((p) => p.charAt(0).toUpperCase() + p.slice(1)).join(""); +} + +async function formatCSharpFile(filePath: string): Promise { + try { + const projectFile = path.join(REPO_ROOT, "dotnet/src/GitHub.Copilot.SDK.csproj"); + await execFileAsync("dotnet", ["format", projectFile, "--include", filePath]); + console.log(` ✓ Formatted with dotnet format`); + } catch { + // dotnet format not available, skip + } +} + +function collectRpcMethods(node: Record): RpcMethod[] { + const results: RpcMethod[] = []; + for (const value of Object.values(node)) { + if (isRpcMethod(value)) { + results.push(value); + } else if (typeof value === "object" && value !== null) { + results.push(...collectRpcMethods(value as Record)); + } + } + return results; +} + +function schemaTypeToCSharp(schema: JSONSchema7, required: boolean, knownTypes: Map): string { + if (schema.anyOf) { + const nonNull = schema.anyOf.filter((s) => typeof s === "object" && s.type !== "null"); + if (nonNull.length === 1 && typeof nonNull[0] === "object") { + return schemaTypeToCSharp(nonNull[0] as JSONSchema7, false, knownTypes) + "?"; + } + } + if (schema.$ref) { + const refName = schema.$ref.split("/").pop()!; + return knownTypes.get(refName) || refName; + } + const type = schema.type; + const format = schema.format; + if (type === "string") { + if (format === "uuid") return required ? "Guid" : "Guid?"; + if (format === "date-time") return required ? "DateTimeOffset" : "DateTimeOffset?"; + return required ? "string" : "string?"; + } + if (type === "number" || type === "integer") return required ? "double" : "double?"; + if (type === "boolean") return required ? "bool" : "bool?"; + if (type === "array") { + const items = schema.items as JSONSchema7 | undefined; + const itemType = items ? schemaTypeToCSharp(items, true, knownTypes) : "object"; + return required ? `${itemType}[]` : `${itemType}[]?`; + } + if (type === "object") { + if (schema.additionalProperties && typeof schema.additionalProperties === "object") { + const valueType = schemaTypeToCSharp(schema.additionalProperties as JSONSchema7, true, knownTypes); + return required ? `Dictionary` : `Dictionary?`; + } + return required ? "object" : "object?"; + } + return required ? "object" : "object?"; +} + +const COPYRIGHT = `/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/`; + +// ══════════════════════════════════════════════════════════════════════════════ +// SESSION EVENTS +// ══════════════════════════════════════════════════════════════════════════════ + +interface EventVariant { + typeName: string; + className: string; + dataClassName: string; + dataSchema: JSONSchema7; +} + +let generatedEnums = new Map(); + +function getOrCreateEnum(parentClassName: string, propName: string, values: string[], enumOutput: string[]): string { + const valuesKey = [...values].sort().join("|"); + for (const [, existing] of generatedEnums) { + if ([...existing.values].sort().join("|") === valuesKey) return existing.enumName; + } + const enumName = `${parentClassName}${propName}`; + generatedEnums.set(enumName, { enumName, values }); + + const lines = [`[JsonConverter(typeof(JsonStringEnumConverter<${enumName}>))]`, `public enum ${enumName}`, `{`]; + for (const value of values) { + lines.push(` [JsonStringEnumMemberName("${value}")]`, ` ${toPascalCaseEnumMember(value)},`); + } + lines.push(`}`, ""); + enumOutput.push(lines.join("\n")); + return enumName; +} + +function extractEventVariants(schema: JSONSchema7): EventVariant[] { + const sessionEvent = schema.definitions?.SessionEvent as JSONSchema7; + if (!sessionEvent?.anyOf) throw new Error("Schema must have SessionEvent definition with anyOf"); + + return sessionEvent.anyOf + .map((variant) => { + if (typeof variant !== "object" || !variant.properties) throw new Error("Invalid variant"); + const typeSchema = variant.properties.type as JSONSchema7; + const typeName = typeSchema?.const as string; + if (!typeName) throw new Error("Variant must have type.const"); + const baseName = typeToClassName(typeName); + return { + typeName, + className: `${baseName}Event`, + dataClassName: `${baseName}Data`, + dataSchema: variant.properties.data as JSONSchema7, + }; + }) + .filter((v) => !EXCLUDED_EVENT_TYPES.has(v.typeName)); +} + +/** + * Find a discriminator property shared by all variants in an anyOf. + */ +function findDiscriminator(variants: JSONSchema7[]): { property: string; mapping: Map } | null { + if (variants.length === 0) return null; + const firstVariant = variants[0]; + if (!firstVariant.properties) return null; + + for (const [propName, propSchema] of Object.entries(firstVariant.properties)) { + if (typeof propSchema !== "object") continue; + const schema = propSchema as JSONSchema7; + if (schema.const === undefined) continue; + + const mapping = new Map(); + let isValidDiscriminator = true; + + for (const variant of variants) { + if (!variant.properties) { isValidDiscriminator = false; break; } + const variantProp = variant.properties[propName]; + if (typeof variantProp !== "object") { isValidDiscriminator = false; break; } + const variantSchema = variantProp as JSONSchema7; + if (variantSchema.const === undefined) { isValidDiscriminator = false; break; } + mapping.set(String(variantSchema.const), variant); + } + + if (isValidDiscriminator && mapping.size === variants.length) { + return { property: propName, mapping }; + } + } + return null; +} + +/** + * Generate a polymorphic base class and derived classes for a discriminated union. + */ +function generatePolymorphicClasses( + baseClassName: string, + discriminatorProperty: string, + variants: JSONSchema7[], + knownTypes: Map, + nestedClasses: Map, + enumOutput: string[] +): string { + const lines: string[] = []; + const discriminatorInfo = findDiscriminator(variants)!; + + lines.push(`[JsonPolymorphic(`); + lines.push(` TypeDiscriminatorPropertyName = "${discriminatorProperty}",`); + lines.push(` UnknownDerivedTypeHandling = JsonUnknownDerivedTypeHandling.FallBackToBaseType)]`); + + for (const [constValue] of discriminatorInfo.mapping) { + const derivedClassName = `${baseClassName}${toPascalCase(constValue)}`; + lines.push(`[JsonDerivedType(typeof(${derivedClassName}), "${constValue}")]`); + } + + lines.push(`public partial class ${baseClassName}`); + lines.push(`{`); + lines.push(` [JsonPropertyName("${discriminatorProperty}")]`); + lines.push(` public virtual string ${toPascalCase(discriminatorProperty)} { get; set; } = string.Empty;`); + lines.push(`}`); + lines.push(""); + + for (const [constValue, variant] of discriminatorInfo.mapping) { + const derivedClassName = `${baseClassName}${toPascalCase(constValue)}`; + const derivedCode = generateDerivedClass(derivedClassName, baseClassName, discriminatorProperty, constValue, variant, knownTypes, nestedClasses, enumOutput); + nestedClasses.set(derivedClassName, derivedCode); + } + + return lines.join("\n"); +} + +/** + * Generate a derived class for a discriminated union variant. + */ +function generateDerivedClass( + className: string, + baseClassName: string, + discriminatorProperty: string, + discriminatorValue: string, + schema: JSONSchema7, + knownTypes: Map, + nestedClasses: Map, + enumOutput: string[] +): string { + const lines: string[] = []; + const required = new Set(schema.required || []); + + lines.push(`public partial class ${className} : ${baseClassName}`); + lines.push(`{`); + lines.push(` [JsonIgnore]`); + lines.push(` public override string ${toPascalCase(discriminatorProperty)} => "${discriminatorValue}";`); + lines.push(""); + + if (schema.properties) { + for (const [propName, propSchema] of Object.entries(schema.properties)) { + if (typeof propSchema !== "object") continue; + if (propName === discriminatorProperty) continue; + + const isReq = required.has(propName); + const csharpName = toPascalCase(propName); + const csharpType = resolveSessionPropertyType(propSchema as JSONSchema7, className, csharpName, isReq, knownTypes, nestedClasses, enumOutput); + + if (!isReq) lines.push(` [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]`); + lines.push(` [JsonPropertyName("${propName}")]`); + const reqMod = isReq && !csharpType.endsWith("?") ? "required " : ""; + lines.push(` public ${reqMod}${csharpType} ${csharpName} { get; set; }`, ""); + } + } + + if (lines[lines.length - 1] === "") lines.pop(); + lines.push(`}`); + return lines.join("\n"); +} + +function generateNestedClass( + className: string, + schema: JSONSchema7, + knownTypes: Map, + nestedClasses: Map, + enumOutput: string[] +): string { + const required = new Set(schema.required || []); + const lines = [`public partial class ${className}`, `{`]; + + for (const [propName, propSchema] of Object.entries(schema.properties || {})) { + if (typeof propSchema !== "object") continue; + const prop = propSchema as JSONSchema7; + const isReq = required.has(propName); + const csharpName = toPascalCase(propName); + const csharpType = resolveSessionPropertyType(prop, className, csharpName, isReq, knownTypes, nestedClasses, enumOutput); + + if (!isReq) lines.push(` [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]`); + lines.push(` [JsonPropertyName("${propName}")]`); + const reqMod = isReq && !csharpType.endsWith("?") ? "required " : ""; + lines.push(` public ${reqMod}${csharpType} ${csharpName} { get; set; }`, ""); + } + if (lines[lines.length - 1] === "") lines.pop(); + lines.push(`}`); + return lines.join("\n"); +} + +function resolveSessionPropertyType( + propSchema: JSONSchema7, + parentClassName: string, + propName: string, + isRequired: boolean, + knownTypes: Map, + nestedClasses: Map, + enumOutput: string[] +): string { + if (propSchema.anyOf) { + const hasNull = propSchema.anyOf.some((s) => typeof s === "object" && (s as JSONSchema7).type === "null"); + const nonNull = propSchema.anyOf.filter((s) => typeof s === "object" && (s as JSONSchema7).type !== "null"); + if (nonNull.length === 1) { + return resolveSessionPropertyType(nonNull[0] as JSONSchema7, parentClassName, propName, isRequired && !hasNull, knownTypes, nestedClasses, enumOutput); + } + return hasNull || !isRequired ? "object?" : "object"; + } + if (propSchema.enum && Array.isArray(propSchema.enum)) { + const enumName = getOrCreateEnum(parentClassName, propName, propSchema.enum as string[], enumOutput); + return isRequired ? enumName : `${enumName}?`; + } + if (propSchema.type === "object" && propSchema.properties) { + const nestedClassName = `${parentClassName}${propName}`; + nestedClasses.set(nestedClassName, generateNestedClass(nestedClassName, propSchema, knownTypes, nestedClasses, enumOutput)); + return isRequired ? nestedClassName : `${nestedClassName}?`; + } + if (propSchema.type === "array" && propSchema.items) { + const items = propSchema.items as JSONSchema7; + // Array of discriminated union (anyOf with shared discriminator) + if (items.anyOf && Array.isArray(items.anyOf)) { + const variants = items.anyOf.filter((v): v is JSONSchema7 => typeof v === "object"); + const discriminatorInfo = findDiscriminator(variants); + if (discriminatorInfo) { + const baseClassName = `${parentClassName}${propName}Item`; + const polymorphicCode = generatePolymorphicClasses(baseClassName, discriminatorInfo.property, variants, knownTypes, nestedClasses, enumOutput); + nestedClasses.set(baseClassName, polymorphicCode); + return isRequired ? `${baseClassName}[]` : `${baseClassName}[]?`; + } + } + if (items.type === "object" && items.properties) { + const itemClassName = `${parentClassName}${propName}Item`; + nestedClasses.set(itemClassName, generateNestedClass(itemClassName, items, knownTypes, nestedClasses, enumOutput)); + return isRequired ? `${itemClassName}[]` : `${itemClassName}[]?`; + } + if (items.enum && Array.isArray(items.enum)) { + const enumName = getOrCreateEnum(parentClassName, `${propName}Item`, items.enum as string[], enumOutput); + return isRequired ? `${enumName}[]` : `${enumName}[]?`; + } + const itemType = schemaTypeToCSharp(items, true, knownTypes); + return isRequired ? `${itemType}[]` : `${itemType}[]?`; + } + return schemaTypeToCSharp(propSchema, isRequired, knownTypes); +} + +function generateDataClass(variant: EventVariant, knownTypes: Map, nestedClasses: Map, enumOutput: string[]): string { + if (!variant.dataSchema?.properties) return `public partial class ${variant.dataClassName} { }`; + + const required = new Set(variant.dataSchema.required || []); + const lines = [`public partial class ${variant.dataClassName}`, `{`]; + + for (const [propName, propSchema] of Object.entries(variant.dataSchema.properties)) { + if (typeof propSchema !== "object") continue; + const isReq = required.has(propName); + const csharpName = toPascalCase(propName); + const csharpType = resolveSessionPropertyType(propSchema as JSONSchema7, variant.dataClassName, csharpName, isReq, knownTypes, nestedClasses, enumOutput); + + if (!isReq) lines.push(` [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]`); + lines.push(` [JsonPropertyName("${propName}")]`); + const reqMod = isReq && !csharpType.endsWith("?") ? "required " : ""; + lines.push(` public ${reqMod}${csharpType} ${csharpName} { get; set; }`, ""); + } + if (lines[lines.length - 1] === "") lines.pop(); + lines.push(`}`); + return lines.join("\n"); +} + +function generateSessionEventsCode(schema: JSONSchema7): string { + generatedEnums.clear(); + const variants = extractEventVariants(schema); + const knownTypes = new Map(); + const nestedClasses = new Map(); + const enumOutput: string[] = []; + + const lines: string[] = []; + lines.push(`${COPYRIGHT} + +// AUTO-GENERATED FILE - DO NOT EDIT +// Generated from: session-events.schema.json + +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace GitHub.Copilot.SDK; +`); + + // Base class with XML doc + lines.push(`/// `); + lines.push(`/// Base class for all session events with polymorphic JSON serialization.`); + lines.push(`/// `); + lines.push(`[JsonPolymorphic(`, ` TypeDiscriminatorPropertyName = "type",`, ` UnknownDerivedTypeHandling = JsonUnknownDerivedTypeHandling.FailSerialization)]`); + for (const variant of [...variants].sort((a, b) => a.typeName.localeCompare(b.typeName))) { + lines.push(`[JsonDerivedType(typeof(${variant.className}), "${variant.typeName}")]`); + } + lines.push(`public abstract partial class SessionEvent`, `{`, ` [JsonPropertyName("id")]`, ` public Guid Id { get; set; }`, ""); + lines.push(` [JsonPropertyName("timestamp")]`, ` public DateTimeOffset Timestamp { get; set; }`, ""); + lines.push(` [JsonPropertyName("parentId")]`, ` public Guid? ParentId { get; set; }`, ""); + lines.push(` [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]`, ` [JsonPropertyName("ephemeral")]`, ` public bool? Ephemeral { get; set; }`, ""); + lines.push(` /// `, ` /// The event type discriminator.`, ` /// `); + lines.push(` [JsonIgnore]`, ` public abstract string Type { get; }`, ""); + lines.push(` public static SessionEvent FromJson(string json) =>`, ` JsonSerializer.Deserialize(json, SessionEventsJsonContext.Default.SessionEvent)!;`, ""); + lines.push(` public string ToJson() =>`, ` JsonSerializer.Serialize(this, SessionEventsJsonContext.Default.SessionEvent);`, `}`, ""); + + // Event classes with XML docs + for (const variant of variants) { + lines.push(`/// `, `/// Event: ${variant.typeName}`, `/// `); + lines.push(`public partial class ${variant.className} : SessionEvent`, `{`, ` [JsonIgnore]`, ` public override string Type => "${variant.typeName}";`, ""); + lines.push(` [JsonPropertyName("data")]`, ` public required ${variant.dataClassName} Data { get; set; }`, `}`, ""); + } + + // Data classes + for (const variant of variants) { + lines.push(generateDataClass(variant, knownTypes, nestedClasses, enumOutput), ""); + } + + // Nested classes + for (const [, code] of nestedClasses) lines.push(code, ""); + + // Enums + for (const code of enumOutput) lines.push(code); + + // JsonSerializerContext + const types = ["SessionEvent", ...variants.flatMap((v) => [v.className, v.dataClassName]), ...nestedClasses.keys()].sort(); + lines.push(`[JsonSourceGenerationOptions(`, ` JsonSerializerDefaults.Web,`, ` AllowOutOfOrderMetadataProperties = true,`, ` NumberHandling = JsonNumberHandling.AllowReadingFromString,`, ` DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)]`); + for (const t of types) lines.push(`[JsonSerializable(typeof(${t}))]`); + lines.push(`internal partial class SessionEventsJsonContext : JsonSerializerContext;`); + + return lines.join("\n"); +} + +export async function generateSessionEvents(schemaPath?: string): Promise { + console.log("C#: generating session-events..."); + const resolvedPath = schemaPath ?? (await getSessionEventsSchemaPath()); + const schema = JSON.parse(await fs.readFile(resolvedPath, "utf-8")) as JSONSchema7; + const code = generateSessionEventsCode(schema); + const outPath = await writeGeneratedFile("dotnet/src/Generated/SessionEvents.cs", code); + console.log(` ✓ ${outPath}`); + await formatCSharpFile(outPath); +} + +// ══════════════════════════════════════════════════════════════════════════════ +// RPC TYPES +// ══════════════════════════════════════════════════════════════════════════════ + +let emittedRpcClasses = new Set(); +let rpcKnownTypes = new Map(); + +function singularPascal(s: string): string { + const p = toPascalCase(s); + return p.endsWith("s") ? p.slice(0, -1) : p; +} + +function resolveRpcType(schema: JSONSchema7, isRequired: boolean, parentClassName: string, propName: string, classes: string[]): string { + if (schema.type === "object" && schema.properties) { + const className = `${parentClassName}${propName}`; + classes.push(emitRpcClass(className, schema, "public", classes)); + return isRequired ? className : `${className}?`; + } + if (schema.type === "array" && schema.items) { + const items = schema.items as JSONSchema7; + if (items.type === "object" && items.properties) { + const itemClass = singularPascal(propName); + if (!emittedRpcClasses.has(itemClass)) classes.push(emitRpcClass(itemClass, items, "public", classes)); + return isRequired ? `List<${itemClass}>` : `List<${itemClass}>?`; + } + const itemType = schemaTypeToCSharp(items, true, rpcKnownTypes); + return isRequired ? `List<${itemType}>` : `List<${itemType}>?`; + } + if (schema.type === "object" && schema.additionalProperties && typeof schema.additionalProperties === "object") { + const vs = schema.additionalProperties as JSONSchema7; + if (vs.type === "object" && vs.properties) { + const valClass = `${parentClassName}${propName}Value`; + classes.push(emitRpcClass(valClass, vs, "public", classes)); + return isRequired ? `Dictionary` : `Dictionary?`; + } + const valueType = schemaTypeToCSharp(vs, true, rpcKnownTypes); + return isRequired ? `Dictionary` : `Dictionary?`; + } + return schemaTypeToCSharp(schema, isRequired, rpcKnownTypes); +} + +function emitRpcClass(className: string, schema: JSONSchema7, visibility: "public" | "internal", extraClasses: string[]): string { + if (emittedRpcClasses.has(className)) return ""; + emittedRpcClasses.add(className); + + const requiredSet = new Set(schema.required || []); + const lines: string[] = []; + if (schema.description) lines.push(`/// ${schema.description}`); + lines.push(`${visibility} class ${className}`, `{`); + + const props = Object.entries(schema.properties || {}); + for (let i = 0; i < props.length; i++) { + const [propName, propSchema] = props[i]; + if (typeof propSchema !== "object") continue; + const prop = propSchema as JSONSchema7; + const isReq = requiredSet.has(propName); + const csharpName = toPascalCase(propName); + const csharpType = resolveRpcType(prop, isReq, className, csharpName, extraClasses); + + if (prop.description && visibility === "public") lines.push(` /// ${prop.description}`); + lines.push(` [JsonPropertyName("${propName}")]`); + + let defaultVal = ""; + if (isReq && !csharpType.endsWith("?")) { + if (csharpType === "string") defaultVal = " = string.Empty;"; + else if (csharpType.startsWith("List<") || csharpType.startsWith("Dictionary<") || emittedRpcClasses.has(csharpType)) defaultVal = " = new();"; + } + lines.push(` public ${csharpType} ${csharpName} { get; set; }${defaultVal}`); + if (i < props.length - 1) lines.push(""); + } + lines.push(`}`); + return lines.join("\n"); +} + +/** + * Emit ServerRpc as an instance class (like SessionRpc but without sessionId). + */ +function emitServerRpcClasses(node: Record, classes: string[]): string[] { + const result: string[] = []; + + // Find top-level groups (e.g. "models", "tools", "account") + const groups = Object.entries(node).filter(([, v]) => typeof v === "object" && v !== null && !isRpcMethod(v)); + // Find top-level methods (e.g. "ping") + const topLevelMethods = Object.entries(node).filter(([, v]) => isRpcMethod(v)); + + // ServerRpc class + const srLines: string[] = []; + srLines.push(`/// Typed server-scoped RPC methods (no session required).`); + srLines.push(`public class ServerRpc`); + srLines.push(`{`); + srLines.push(` private readonly JsonRpc _rpc;`); + srLines.push(""); + srLines.push(` internal ServerRpc(JsonRpc rpc)`); + srLines.push(` {`); + srLines.push(` _rpc = rpc;`); + for (const [groupName] of groups) { + srLines.push(` ${toPascalCase(groupName)} = new ${toPascalCase(groupName)}Api(rpc);`); + } + srLines.push(` }`); + + // Top-level methods (like ping) + for (const [key, value] of topLevelMethods) { + if (!isRpcMethod(value)) continue; + emitServerInstanceMethod(key, value, srLines, classes, " "); + } + + // Group properties + for (const [groupName] of groups) { + srLines.push(""); + srLines.push(` /// ${toPascalCase(groupName)} APIs.`); + srLines.push(` public ${toPascalCase(groupName)}Api ${toPascalCase(groupName)} { get; }`); + } + + srLines.push(`}`); + result.push(srLines.join("\n")); + + // Per-group API classes + for (const [groupName, groupNode] of groups) { + result.push(emitServerApiClass(`${toPascalCase(groupName)}Api`, groupNode as Record, classes)); + } + + return result; +} + +function emitServerApiClass(className: string, node: Record, classes: string[]): string { + const lines: string[] = []; + lines.push(`/// Server-scoped ${className.replace("Api", "")} APIs.`); + lines.push(`public class ${className}`); + lines.push(`{`); + lines.push(` private readonly JsonRpc _rpc;`); + lines.push(""); + lines.push(` internal ${className}(JsonRpc rpc)`); + lines.push(` {`); + lines.push(` _rpc = rpc;`); + lines.push(` }`); + + for (const [key, value] of Object.entries(node)) { + if (!isRpcMethod(value)) continue; + emitServerInstanceMethod(key, value, lines, classes, " "); + } + + lines.push(`}`); + return lines.join("\n"); +} + +function emitServerInstanceMethod( + name: string, + method: { rpcMethod: string; params: JSONSchema7 | null; result: JSONSchema7 }, + lines: string[], + classes: string[], + indent: string +): void { + const methodName = toPascalCase(name); + const resultClassName = `${typeToClassName(method.rpcMethod)}Result`; + const resultClass = emitRpcClass(resultClassName, method.result, "public", classes); + if (resultClass) classes.push(resultClass); + + const paramEntries = method.params?.properties ? Object.entries(method.params.properties) : []; + const requiredSet = new Set(method.params?.required || []); + + let requestClassName: string | null = null; + if (paramEntries.length > 0) { + requestClassName = `${methodName}Request`; + const reqClass = emitRpcClass(requestClassName, method.params!, "internal", classes); + if (reqClass) classes.push(reqClass); + } + + lines.push(""); + lines.push(`${indent}/// Calls "${method.rpcMethod}".`); + + const sigParams: string[] = []; + const bodyAssignments: string[] = []; + + for (const [pName, pSchema] of paramEntries) { + if (typeof pSchema !== "object") continue; + const isReq = requiredSet.has(pName); + const csType = schemaTypeToCSharp(pSchema as JSONSchema7, isReq, rpcKnownTypes); + sigParams.push(`${csType} ${pName}${isReq ? "" : " = null"}`); + bodyAssignments.push(`${toPascalCase(pName)} = ${pName}`); + } + sigParams.push("CancellationToken cancellationToken = default"); + + lines.push(`${indent}public async Task<${resultClassName}> ${methodName}Async(${sigParams.join(", ")})`); + lines.push(`${indent}{`); + if (requestClassName && bodyAssignments.length > 0) { + lines.push(`${indent} var request = new ${requestClassName} { ${bodyAssignments.join(", ")} };`); + lines.push(`${indent} return await CopilotClient.InvokeRpcAsync<${resultClassName}>(_rpc, "${method.rpcMethod}", [request], cancellationToken);`); + } else { + lines.push(`${indent} return await CopilotClient.InvokeRpcAsync<${resultClassName}>(_rpc, "${method.rpcMethod}", [], cancellationToken);`); + } + lines.push(`${indent}}`); +} + +function emitSessionRpcClasses(node: Record, classes: string[]): string[] { + const result: string[] = []; + const groups = Object.entries(node).filter(([, v]) => typeof v === "object" && v !== null && !isRpcMethod(v)); + + const srLines = [`/// Typed session-scoped RPC methods.`, `public class SessionRpc`, `{`, ` private readonly JsonRpc _rpc;`, ` private readonly string _sessionId;`, ""]; + srLines.push(` internal SessionRpc(JsonRpc rpc, string sessionId)`, ` {`, ` _rpc = rpc;`, ` _sessionId = sessionId;`); + for (const [groupName] of groups) srLines.push(` ${toPascalCase(groupName)} = new ${toPascalCase(groupName)}Api(rpc, sessionId);`); + srLines.push(` }`); + for (const [groupName] of groups) srLines.push("", ` public ${toPascalCase(groupName)}Api ${toPascalCase(groupName)} { get; }`); + srLines.push(`}`); + result.push(srLines.join("\n")); + + for (const [groupName, groupNode] of groups) { + result.push(emitSessionApiClass(`${toPascalCase(groupName)}Api`, groupNode as Record, classes)); + } + return result; +} + +function emitSessionApiClass(className: string, node: Record, classes: string[]): string { + const lines = [`public class ${className}`, `{`, ` private readonly JsonRpc _rpc;`, ` private readonly string _sessionId;`, ""]; + lines.push(` internal ${className}(JsonRpc rpc, string sessionId)`, ` {`, ` _rpc = rpc;`, ` _sessionId = sessionId;`, ` }`); + + for (const [key, value] of Object.entries(node)) { + if (!isRpcMethod(value)) continue; + const method = value; + const methodName = toPascalCase(key); + const resultClassName = `${typeToClassName(method.rpcMethod)}Result`; + const resultClass = emitRpcClass(resultClassName, method.result, "public", classes); + if (resultClass) classes.push(resultClass); + + const paramEntries = (method.params?.properties ? Object.entries(method.params.properties) : []).filter(([k]) => k !== "sessionId"); + const requiredSet = new Set(method.params?.required || []); + + const requestClassName = `${methodName}Request`; + if (method.params) { + const reqClass = emitRpcClass(requestClassName, method.params, "internal", classes); + if (reqClass) classes.push(reqClass); + } + + lines.push("", ` /// Calls "${method.rpcMethod}".`); + const sigParams: string[] = []; + const bodyAssignments = [`SessionId = _sessionId`]; + + for (const [pName, pSchema] of paramEntries) { + if (typeof pSchema !== "object") continue; + const csType = schemaTypeToCSharp(pSchema as JSONSchema7, requiredSet.has(pName), rpcKnownTypes); + sigParams.push(`${csType} ${pName}`); + bodyAssignments.push(`${toPascalCase(pName)} = ${pName}`); + } + sigParams.push("CancellationToken cancellationToken = default"); + + lines.push(` public async Task<${resultClassName}> ${methodName}Async(${sigParams.join(", ")})`); + lines.push(` {`, ` var request = new ${requestClassName} { ${bodyAssignments.join(", ")} };`); + lines.push(` return await CopilotClient.InvokeRpcAsync<${resultClassName}>(_rpc, "${method.rpcMethod}", [request], cancellationToken);`, ` }`); + } + lines.push(`}`); + return lines.join("\n"); +} + +function generateRpcCode(schema: ApiSchema): string { + emittedRpcClasses.clear(); + rpcKnownTypes.clear(); + const classes: string[] = []; + + let serverRpcParts: string[] = []; + if (schema.server) serverRpcParts = emitServerRpcClasses(schema.server, classes); + + let sessionRpcParts: string[] = []; + if (schema.session) sessionRpcParts = emitSessionRpcClasses(schema.session, classes); + + const lines: string[] = []; + lines.push(`${COPYRIGHT} + +// AUTO-GENERATED FILE - DO NOT EDIT +// Generated from: api.schema.json + +using System.Text.Json; +using System.Text.Json.Serialization; +using StreamJsonRpc; + +namespace GitHub.Copilot.SDK.Rpc; +`); + + for (const cls of classes) if (cls) lines.push(cls, ""); + for (const part of serverRpcParts) lines.push(part, ""); + for (const part of sessionRpcParts) lines.push(part, ""); + + // Add JsonSerializerContext for AOT/trimming support + const typeNames = [...emittedRpcClasses].sort(); + if (typeNames.length > 0) { + lines.push(`[JsonSourceGenerationOptions(`); + lines.push(` JsonSerializerDefaults.Web,`); + lines.push(` AllowOutOfOrderMetadataProperties = true,`); + lines.push(` DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)]`); + for (const t of typeNames) lines.push(`[JsonSerializable(typeof(${t}))]`); + lines.push(`internal partial class RpcJsonContext : JsonSerializerContext;`); + } + + return lines.join("\n"); +} + +export async function generateRpc(schemaPath?: string): Promise { + console.log("C#: generating RPC types..."); + const resolvedPath = schemaPath ?? (await getApiSchemaPath()); + const schema = JSON.parse(await fs.readFile(resolvedPath, "utf-8")) as ApiSchema; + const code = generateRpcCode(schema); + const outPath = await writeGeneratedFile("dotnet/src/Generated/Rpc.cs", code); + console.log(` ✓ ${outPath}`); + await formatCSharpFile(outPath); +} + +// ══════════════════════════════════════════════════════════════════════════════ +// MAIN +// ══════════════════════════════════════════════════════════════════════════════ + +async function generate(sessionSchemaPath?: string, apiSchemaPath?: string): Promise { + await generateSessionEvents(sessionSchemaPath); + try { + await generateRpc(apiSchemaPath); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === "ENOENT" && !apiSchemaPath) { + console.log("C#: skipping RPC (api.schema.json not found)"); + } else { + throw err; + } + } +} + +const sessionArg = process.argv[2] || undefined; +const apiArg = process.argv[3] || undefined; +generate(sessionArg, apiArg).catch((err) => { + console.error("C# generation failed:", err); + process.exit(1); +}); diff --git a/scripts/codegen/go.ts b/scripts/codegen/go.ts new file mode 100644 index 00000000..6f0812a1 --- /dev/null +++ b/scripts/codegen/go.ts @@ -0,0 +1,302 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +/** + * Go code generator for session-events and RPC types. + */ + +import { execFile } from "child_process"; +import fs from "fs/promises"; +import { promisify } from "util"; +import type { JSONSchema7 } from "json-schema"; +import { FetchingJSONSchemaStore, InputData, JSONSchemaInput, quicktype } from "quicktype-core"; +import { + getSessionEventsSchemaPath, + getApiSchemaPath, + postProcessSchema, + writeGeneratedFile, + isRpcMethod, + type ApiSchema, + type RpcMethod, +} from "./utils.js"; + +const execFileAsync = promisify(execFile); + +// ── Utilities ─────────────────────────────────────────────────────────────── + +// Go initialisms that should be all-caps +const goInitialisms = new Set(["id", "url", "api", "http", "https", "json", "xml", "html", "css", "sql", "ssh", "tcp", "udp", "ip", "rpc"]); + +function toPascalCase(s: string): string { + return s + .split(/[._]/) + .map((w) => goInitialisms.has(w.toLowerCase()) ? w.toUpperCase() : w.charAt(0).toUpperCase() + w.slice(1)) + .join(""); +} + +function toGoFieldName(jsonName: string): string { + // Handle camelCase field names like "modelId" -> "ModelID" + return jsonName + .replace(/([a-z])([A-Z])/g, "$1_$2") + .split("_") + .map((w) => goInitialisms.has(w.toLowerCase()) ? w.toUpperCase() : w.charAt(0).toUpperCase() + w.slice(1).toLowerCase()) + .join(""); +} + +async function formatGoFile(filePath: string): Promise { + try { + await execFileAsync("go", ["fmt", filePath]); + console.log(` ✓ Formatted with go fmt`); + } catch { + // go fmt not available, skip + } +} + +function collectRpcMethods(node: Record): RpcMethod[] { + const results: RpcMethod[] = []; + for (const value of Object.values(node)) { + if (isRpcMethod(value)) { + results.push(value); + } else if (typeof value === "object" && value !== null) { + results.push(...collectRpcMethods(value as Record)); + } + } + return results; +} + +// ── Session Events ────────────────────────────────────────────────────────── + +async function generateSessionEvents(schemaPath?: string): Promise { + console.log("Go: generating session-events..."); + + const resolvedPath = schemaPath ?? (await getSessionEventsSchemaPath()); + const schema = JSON.parse(await fs.readFile(resolvedPath, "utf-8")) as JSONSchema7; + const resolvedSchema = (schema.definitions?.SessionEvent as JSONSchema7) || schema; + const processed = postProcessSchema(resolvedSchema); + + const schemaInput = new JSONSchemaInput(new FetchingJSONSchemaStore()); + await schemaInput.addSource({ name: "SessionEvent", schema: JSON.stringify(processed) }); + + const inputData = new InputData(); + inputData.addInput(schemaInput); + + const result = await quicktype({ + inputData, + lang: "go", + rendererOptions: { package: "copilot" }, + }); + + const banner = `// AUTO-GENERATED FILE - DO NOT EDIT +// Generated from: session-events.schema.json + +`; + + const outPath = await writeGeneratedFile("go/generated_session_events.go", banner + result.lines.join("\n")); + console.log(` ✓ ${outPath}`); + + await formatGoFile(outPath); +} + +// ── RPC Types ─────────────────────────────────────────────────────────────── + +async function generateRpc(schemaPath?: string): Promise { + console.log("Go: generating RPC types..."); + + const resolvedPath = schemaPath ?? (await getApiSchemaPath()); + const schema = JSON.parse(await fs.readFile(resolvedPath, "utf-8")) as ApiSchema; + + const allMethods = [...collectRpcMethods(schema.server || {}), ...collectRpcMethods(schema.session || {})]; + + // Build a combined schema for quicktype - prefix types to avoid conflicts + const combinedSchema: JSONSchema7 = { + $schema: "http://json-schema.org/draft-07/schema#", + definitions: {}, + }; + + for (const method of allMethods) { + const baseName = toPascalCase(method.rpcMethod); + if (method.result) { + combinedSchema.definitions![baseName + "Result"] = method.result; + } + if (method.params?.properties && Object.keys(method.params.properties).length > 0) { + // For session methods, filter out sessionId from params type + if (method.rpcMethod.startsWith("session.")) { + const filtered: JSONSchema7 = { + ...method.params, + properties: Object.fromEntries( + Object.entries(method.params.properties).filter(([k]) => k !== "sessionId") + ), + required: method.params.required?.filter((r) => r !== "sessionId"), + }; + if (Object.keys(filtered.properties!).length > 0) { + combinedSchema.definitions![baseName + "Params"] = filtered; + } + } else { + combinedSchema.definitions![baseName + "Params"] = method.params; + } + } + } + + // Generate types via quicktype + const schemaInput = new JSONSchemaInput(new FetchingJSONSchemaStore()); + for (const [name, def] of Object.entries(combinedSchema.definitions!)) { + await schemaInput.addSource({ name, schema: JSON.stringify(def) }); + } + + const inputData = new InputData(); + inputData.addInput(schemaInput); + + const qtResult = await quicktype({ + inputData, + lang: "go", + rendererOptions: { package: "copilot", "just-types": "true" }, + }); + + // Build method wrappers + const lines: string[] = []; + lines.push(`// AUTO-GENERATED FILE - DO NOT EDIT`); + lines.push(`// Generated from: api.schema.json`); + lines.push(``); + lines.push(`package rpc`); + lines.push(``); + lines.push(`import (`); + lines.push(` "context"`); + lines.push(` "encoding/json"`); + lines.push(``); + lines.push(` "github.com/github/copilot-sdk/go/internal/jsonrpc2"`); + lines.push(`)`); + lines.push(``); + + // Add quicktype-generated types (skip package line) + const qtLines = qtResult.lines.filter((l) => !l.startsWith("package ")); + lines.push(...qtLines); + lines.push(``); + + // Emit ServerRpc + if (schema.server) { + emitRpcWrapper(lines, schema.server, false); + } + + // Emit SessionRpc + if (schema.session) { + emitRpcWrapper(lines, schema.session, true); + } + + const outPath = await writeGeneratedFile("go/rpc/generated_rpc.go", lines.join("\n")); + console.log(` ✓ ${outPath}`); + + await formatGoFile(outPath); +} + +function emitRpcWrapper(lines: string[], node: Record, isSession: boolean): void { + const groups = Object.entries(node).filter(([, v]) => typeof v === "object" && v !== null && !isRpcMethod(v)); + const topLevelMethods = Object.entries(node).filter(([, v]) => isRpcMethod(v)); + + const wrapperName = isSession ? "SessionRpc" : "ServerRpc"; + const apiSuffix = "RpcApi"; + + // Emit API structs for groups + for (const [groupName, groupNode] of groups) { + const apiName = toPascalCase(groupName) + apiSuffix; + const fields = isSession ? "client *jsonrpc2.Client; sessionID string" : "client *jsonrpc2.Client"; + lines.push(`type ${apiName} struct { ${fields} }`); + lines.push(``); + for (const [key, value] of Object.entries(groupNode as Record)) { + if (!isRpcMethod(value)) continue; + emitMethod(lines, apiName, key, value, isSession); + } + } + + // Emit wrapper struct + lines.push(`// ${wrapperName} provides typed ${isSession ? "session" : "server"}-scoped RPC methods.`); + lines.push(`type ${wrapperName} struct {`); + lines.push(` client *jsonrpc2.Client`); + if (isSession) lines.push(` sessionID string`); + for (const [groupName] of groups) { + lines.push(` ${toPascalCase(groupName)} *${toPascalCase(groupName)}${apiSuffix}`); + } + lines.push(`}`); + lines.push(``); + + // Top-level methods (server only) + for (const [key, value] of topLevelMethods) { + if (!isRpcMethod(value)) continue; + emitMethod(lines, wrapperName, key, value, isSession); + } + + // Constructor + const ctorParams = isSession ? "client *jsonrpc2.Client, sessionID string" : "client *jsonrpc2.Client"; + const ctorFields = isSession ? "client: client, sessionID: sessionID," : "client: client,"; + lines.push(`func New${wrapperName}(${ctorParams}) *${wrapperName} {`); + lines.push(` return &${wrapperName}{${ctorFields}`); + for (const [groupName] of groups) { + const apiInit = isSession + ? `&${toPascalCase(groupName)}${apiSuffix}{client: client, sessionID: sessionID}` + : `&${toPascalCase(groupName)}${apiSuffix}{client: client}`; + lines.push(` ${toPascalCase(groupName)}: ${apiInit},`); + } + lines.push(` }`); + lines.push(`}`); + lines.push(``); +} + +function emitMethod(lines: string[], receiver: string, name: string, method: RpcMethod, isSession: boolean): void { + const methodName = toPascalCase(name); + const resultType = toPascalCase(method.rpcMethod) + "Result"; + + const paramProps = method.params?.properties || {}; + const nonSessionParams = Object.keys(paramProps).filter((k) => k !== "sessionId"); + const hasParams = isSession ? nonSessionParams.length > 0 : Object.keys(paramProps).length > 0; + const paramsType = hasParams ? toPascalCase(method.rpcMethod) + "Params" : ""; + + const sig = hasParams + ? `func (a *${receiver}) ${methodName}(ctx context.Context, params *${paramsType}) (*${resultType}, error)` + : `func (a *${receiver}) ${methodName}(ctx context.Context) (*${resultType}, error)`; + + lines.push(sig + ` {`); + + if (isSession) { + lines.push(` req := map[string]interface{}{"sessionId": a.sessionID}`); + if (hasParams) { + lines.push(` if params != nil {`); + for (const pName of nonSessionParams) { + lines.push(` req["${pName}"] = params.${toGoFieldName(pName)}`); + } + lines.push(` }`); + } + lines.push(` raw, err := a.client.Request("${method.rpcMethod}", req)`); + } else { + const arg = hasParams ? "params" : "map[string]interface{}{}"; + lines.push(` raw, err := a.client.Request("${method.rpcMethod}", ${arg})`); + } + + lines.push(` if err != nil { return nil, err }`); + lines.push(` var result ${resultType}`); + lines.push(` if err := json.Unmarshal(raw, &result); err != nil { return nil, err }`); + lines.push(` return &result, nil`); + lines.push(`}`); + lines.push(``); +} + +// ── Main ──────────────────────────────────────────────────────────────────── + +async function generate(sessionSchemaPath?: string, apiSchemaPath?: string): Promise { + await generateSessionEvents(sessionSchemaPath); + try { + await generateRpc(apiSchemaPath); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === "ENOENT" && !apiSchemaPath) { + console.log("Go: skipping RPC (api.schema.json not found)"); + } else { + throw err; + } + } +} + +const sessionArg = process.argv[2] || undefined; +const apiArg = process.argv[3] || undefined; +generate(sessionArg, apiArg).catch((err) => { + console.error("Go generation failed:", err); + process.exit(1); +}); diff --git a/scripts/codegen/package-lock.json b/scripts/codegen/package-lock.json new file mode 100644 index 00000000..a02811c6 --- /dev/null +++ b/scripts/codegen/package-lock.json @@ -0,0 +1,1030 @@ +{ + "name": "codegen", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "codegen", + "dependencies": { + "json-schema": "^0.4.0", + "json-schema-to-typescript": "^15.0.4", + "quicktype-core": "^23.2.6", + "tsx": "^4.20.6" + } + }, + "node_modules/@apidevtools/json-schema-ref-parser": { + "version": "11.9.3", + "resolved": "https://registry.npmjs.org/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-11.9.3.tgz", + "integrity": "sha512-60vepv88RwcJtSHrD6MjIL6Ta3SOYbgfnkHb+ppAVK+o9mXprRtulx7VlRl3lN3bbvysAfCS7WMVfhUYemB0IQ==", + "license": "MIT", + "dependencies": { + "@jsdevtools/ono": "^7.1.3", + "@types/json-schema": "^7.0.15", + "js-yaml": "^4.1.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/philsturgeon" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", + "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", + "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", + "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", + "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", + "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", + "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", + "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", + "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", + "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", + "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", + "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", + "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", + "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", + "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", + "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", + "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", + "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", + "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", + "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", + "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", + "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", + "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", + "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", + "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", + "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", + "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@glideapps/ts-necessities": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/@glideapps/ts-necessities/-/ts-necessities-2.2.3.tgz", + "integrity": "sha512-gXi0awOZLHk3TbW55GZLCPP6O+y/b5X1pBXKBVckFONSwF1z1E5ND2BGJsghQFah+pW7pkkyFb2VhUQI2qhL5w==", + "license": "MIT" + }, + "node_modules/@jsdevtools/ono": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/@jsdevtools/ono/-/ono-7.1.3.tgz", + "integrity": "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==", + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "license": "MIT" + }, + "node_modules/@types/lodash": { + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-RDvF6wTulMPjrNdCoYRC8gNR880JNGT8uB+REUpC2Ns4pRqQJhGz90wh7rgdXDPpCczF3VGktDuFGVnz8zP7HA==", + "license": "MIT" + }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/browser-or-node": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/browser-or-node/-/browser-or-node-3.0.0.tgz", + "integrity": "sha512-iczIdVJzGEYhP5DqQxYM9Hh7Ztpqqi+CXZpSmX8ALFs9ecXkQIeqRyM6TfxEfMVpwhl3dSuDvxdzzo9sUOIVBQ==", + "license": "MIT" + }, + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/collection-utils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/collection-utils/-/collection-utils-1.0.1.tgz", + "integrity": "sha512-LA2YTIlR7biSpXkKYwwuzGjwL5rjWEZVOSnvdUc7gObvWe4WkjxOpfrdhoP7Hs09YWDVfg0Mal9BpAqLfVEzQg==", + "license": "Apache-2.0" + }, + "node_modules/cross-fetch": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-4.1.0.tgz", + "integrity": "sha512-uKm5PU+MHTootlWEY+mZ4vvXoCn4fLQxT9dSc1sXVMSFkINTJVN8cAQROpwcKm8bJ/c7rgZVIBWzH5T78sNZZw==", + "license": "MIT", + "dependencies": { + "node-fetch": "^2.7.0" + } + }, + "node_modules/esbuild": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", + "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.3", + "@esbuild/android-arm": "0.27.3", + "@esbuild/android-arm64": "0.27.3", + "@esbuild/android-x64": "0.27.3", + "@esbuild/darwin-arm64": "0.27.3", + "@esbuild/darwin-x64": "0.27.3", + "@esbuild/freebsd-arm64": "0.27.3", + "@esbuild/freebsd-x64": "0.27.3", + "@esbuild/linux-arm": "0.27.3", + "@esbuild/linux-arm64": "0.27.3", + "@esbuild/linux-ia32": "0.27.3", + "@esbuild/linux-loong64": "0.27.3", + "@esbuild/linux-mips64el": "0.27.3", + "@esbuild/linux-ppc64": "0.27.3", + "@esbuild/linux-riscv64": "0.27.3", + "@esbuild/linux-s390x": "0.27.3", + "@esbuild/linux-x64": "0.27.3", + "@esbuild/netbsd-arm64": "0.27.3", + "@esbuild/netbsd-x64": "0.27.3", + "@esbuild/openbsd-arm64": "0.27.3", + "@esbuild/openbsd-x64": "0.27.3", + "@esbuild/openharmony-arm64": "0.27.3", + "@esbuild/sunos-x64": "0.27.3", + "@esbuild/win32-arm64": "0.27.3", + "@esbuild/win32-ia32": "0.27.3", + "@esbuild/win32-x64": "0.27.3" + } + }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/get-tsconfig": { + "version": "4.13.6", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz", + "integrity": "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==", + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-url": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/is-url/-/is-url-1.2.4.tgz", + "integrity": "sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==", + "license": "MIT" + }, + "node_modules/js-base64": { + "version": "3.7.8", + "resolved": "https://registry.npmjs.org/js-base64/-/js-base64-3.7.8.tgz", + "integrity": "sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow==", + "license": "BSD-3-Clause" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-schema": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", + "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==", + "license": "(AFL-2.1 OR BSD-3-Clause)" + }, + "node_modules/json-schema-to-typescript": { + "version": "15.0.4", + "resolved": "https://registry.npmjs.org/json-schema-to-typescript/-/json-schema-to-typescript-15.0.4.tgz", + "integrity": "sha512-Su9oK8DR4xCmDsLlyvadkXzX6+GGXJpbhwoLtOGArAG61dvbW4YQmSEno2y66ahpIdmLMg6YUf/QHLgiwvkrHQ==", + "license": "MIT", + "dependencies": { + "@apidevtools/json-schema-ref-parser": "^11.5.5", + "@types/json-schema": "^7.0.15", + "@types/lodash": "^4.17.7", + "is-glob": "^4.0.3", + "js-yaml": "^4.1.0", + "lodash": "^4.17.21", + "minimist": "^1.2.8", + "prettier": "^3.2.5", + "tinyglobby": "^0.2.9" + }, + "bin": { + "json2ts": "dist/src/cli.js" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/lodash": { + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "license": "MIT" + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pluralize": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz", + "integrity": "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/prettier": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", + "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, + "node_modules/quicktype-core": { + "version": "23.2.6", + "resolved": "https://registry.npmjs.org/quicktype-core/-/quicktype-core-23.2.6.tgz", + "integrity": "sha512-asfeSv7BKBNVb9WiYhFRBvBZHcRutPRBwJMxW0pefluK4kkKu4lv0IvZBwFKvw2XygLcL1Rl90zxWDHYgkwCmA==", + "license": "Apache-2.0", + "dependencies": { + "@glideapps/ts-necessities": "2.2.3", + "browser-or-node": "^3.0.0", + "collection-utils": "^1.0.1", + "cross-fetch": "^4.0.0", + "is-url": "^1.2.4", + "js-base64": "^3.7.7", + "lodash": "^4.17.21", + "pako": "^1.0.6", + "pluralize": "^8.0.0", + "readable-stream": "4.5.2", + "unicode-properties": "^1.4.1", + "urijs": "^1.19.1", + "wordwrap": "^1.0.0", + "yaml": "^2.4.1" + } + }, + "node_modules/readable-stream": { + "version": "4.5.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.5.2.tgz", + "integrity": "sha512-yjavECdqeZ3GLXNgRXgeQEdz9fvDDkNKyHnbHRFtOr7/LcfgBcmct7t/ET+HaCTqfh06OzoAxrkN/IfjJBVe+g==", + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/tiny-inflate": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz", + "integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==", + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "license": "MIT", + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/unicode-properties": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/unicode-properties/-/unicode-properties-1.4.1.tgz", + "integrity": "sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==", + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.0", + "unicode-trie": "^2.0.0" + } + }, + "node_modules/unicode-trie": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unicode-trie/-/unicode-trie-2.0.0.tgz", + "integrity": "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==", + "license": "MIT", + "dependencies": { + "pako": "^0.2.5", + "tiny-inflate": "^1.0.0" + } + }, + "node_modules/unicode-trie/node_modules/pako": { + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz", + "integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==", + "license": "MIT" + }, + "node_modules/urijs": { + "version": "1.19.11", + "resolved": "https://registry.npmjs.org/urijs/-/urijs-1.19.11.tgz", + "integrity": "sha512-HXgFDgDommxn5/bIv0cnQZsPhHDA90NPHD6+c/v21U5+Sx5hoP8+dP9IZXBU1gIfvdRfhG8cel9QNPeionfcCQ==", + "license": "MIT" + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/wordwrap": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", + "integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==", + "license": "MIT" + }, + "node_modules/yaml": { + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", + "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + } + } +} diff --git a/scripts/codegen/package.json b/scripts/codegen/package.json new file mode 100644 index 00000000..a2df5dde --- /dev/null +++ b/scripts/codegen/package.json @@ -0,0 +1,18 @@ +{ + "name": "codegen", + "private": true, + "type": "module", + "scripts": { + "generate": "tsx typescript.ts && tsx csharp.ts && tsx python.ts && tsx go.ts", + "generate:ts": "tsx typescript.ts", + "generate:csharp": "tsx csharp.ts", + "generate:python": "tsx python.ts", + "generate:go": "tsx go.ts" + }, + "dependencies": { + "json-schema": "^0.4.0", + "json-schema-to-typescript": "^15.0.4", + "quicktype-core": "^23.2.6", + "tsx": "^4.20.6" + } +} diff --git a/scripts/codegen/python.ts b/scripts/codegen/python.ts new file mode 100644 index 00000000..1080f632 --- /dev/null +++ b/scripts/codegen/python.ts @@ -0,0 +1,303 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +/** + * Python code generator for session-events and RPC types. + */ + +import fs from "fs/promises"; +import type { JSONSchema7 } from "json-schema"; +import { FetchingJSONSchemaStore, InputData, JSONSchemaInput, quicktype } from "quicktype-core"; +import { + getSessionEventsSchemaPath, + getApiSchemaPath, + postProcessSchema, + writeGeneratedFile, + isRpcMethod, + type ApiSchema, + type RpcMethod, +} from "./utils.js"; + +// ── Utilities ─────────────────────────────────────────────────────────────── + +function toSnakeCase(s: string): string { + return s + .replace(/([a-z])([A-Z])/g, "$1_$2") + .replace(/[._]/g, "_") + .toLowerCase(); +} + +function toPascalCase(s: string): string { + return s + .split(/[._]/) + .map((w) => w.charAt(0).toUpperCase() + w.slice(1)) + .join(""); +} + +function collectRpcMethods(node: Record): RpcMethod[] { + const results: RpcMethod[] = []; + for (const value of Object.values(node)) { + if (isRpcMethod(value)) { + results.push(value); + } else if (typeof value === "object" && value !== null) { + results.push(...collectRpcMethods(value as Record)); + } + } + return results; +} + +// ── Session Events ────────────────────────────────────────────────────────── + +async function generateSessionEvents(schemaPath?: string): Promise { + console.log("Python: generating session-events..."); + + const resolvedPath = schemaPath ?? (await getSessionEventsSchemaPath()); + const schema = JSON.parse(await fs.readFile(resolvedPath, "utf-8")) as JSONSchema7; + const resolvedSchema = (schema.definitions?.SessionEvent as JSONSchema7) || schema; + const processed = postProcessSchema(resolvedSchema); + + const schemaInput = new JSONSchemaInput(new FetchingJSONSchemaStore()); + await schemaInput.addSource({ name: "SessionEvent", schema: JSON.stringify(processed) }); + + const inputData = new InputData(); + inputData.addInput(schemaInput); + + const result = await quicktype({ + inputData, + lang: "python", + rendererOptions: { "python-version": "3.7" }, + }); + + let code = result.lines.join("\n"); + + // Fix dataclass field ordering (Any fields need defaults) + code = code.replace(/: Any$/gm, ": Any = None"); + // Fix bare except: to use Exception (required by ruff/pylint) + code = code.replace(/except:/g, "except Exception:"); + + // Add UNKNOWN enum value for forward compatibility + code = code.replace( + /^(class SessionEventType\(Enum\):.*?)(^\s*\n@dataclass)/ms, + `$1 # UNKNOWN is used for forward compatibility + UNKNOWN = "unknown" + + @classmethod + def _missing_(cls, value: object) -> "SessionEventType": + """Handle unknown event types gracefully for forward compatibility.""" + return cls.UNKNOWN + +$2` + ); + + const banner = `""" +AUTO-GENERATED FILE - DO NOT EDIT +Generated from: session-events.schema.json +""" + +`; + + const outPath = await writeGeneratedFile("python/copilot/generated/session_events.py", banner + code); + console.log(` ✓ ${outPath}`); +} + +// ── RPC Types ─────────────────────────────────────────────────────────────── + +async function generateRpc(schemaPath?: string): Promise { + console.log("Python: generating RPC types..."); + + const resolvedPath = schemaPath ?? (await getApiSchemaPath()); + const schema = JSON.parse(await fs.readFile(resolvedPath, "utf-8")) as ApiSchema; + + const allMethods = [...collectRpcMethods(schema.server || {}), ...collectRpcMethods(schema.session || {})]; + + // Build a combined schema for quicktype + const combinedSchema: JSONSchema7 = { + $schema: "http://json-schema.org/draft-07/schema#", + definitions: {}, + }; + + for (const method of allMethods) { + const baseName = toPascalCase(method.rpcMethod); + if (method.result) { + combinedSchema.definitions![baseName + "Result"] = method.result; + } + if (method.params?.properties && Object.keys(method.params.properties).length > 0) { + if (method.rpcMethod.startsWith("session.")) { + const filtered: JSONSchema7 = { + ...method.params, + properties: Object.fromEntries( + Object.entries(method.params.properties).filter(([k]) => k !== "sessionId") + ), + required: method.params.required?.filter((r) => r !== "sessionId"), + }; + if (Object.keys(filtered.properties!).length > 0) { + combinedSchema.definitions![baseName + "Params"] = filtered; + } + } else { + combinedSchema.definitions![baseName + "Params"] = method.params; + } + } + } + + // Generate types via quicktype + const schemaInput = new JSONSchemaInput(new FetchingJSONSchemaStore()); + for (const [name, def] of Object.entries(combinedSchema.definitions!)) { + await schemaInput.addSource({ name, schema: JSON.stringify(def) }); + } + + const inputData = new InputData(); + inputData.addInput(schemaInput); + + const qtResult = await quicktype({ + inputData, + lang: "python", + rendererOptions: { "python-version": "3.7" }, + }); + + let typesCode = qtResult.lines.join("\n"); + // Fix dataclass field ordering + typesCode = typesCode.replace(/: Any$/gm, ": Any = None"); + // Fix bare except: to use Exception (required by ruff/pylint) + typesCode = typesCode.replace(/except:/g, "except Exception:"); + + const lines: string[] = []; + lines.push(`""" +AUTO-GENERATED FILE - DO NOT EDIT +Generated from: api.schema.json +""" + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from ..jsonrpc import JsonRpcClient + +`); + lines.push(typesCode); + lines.push(``); + + // Emit RPC wrapper classes + if (schema.server) { + emitRpcWrapper(lines, schema.server, false); + } + if (schema.session) { + emitRpcWrapper(lines, schema.session, true); + } + + const outPath = await writeGeneratedFile("python/copilot/generated/rpc.py", lines.join("\n")); + console.log(` ✓ ${outPath}`); +} + +function emitRpcWrapper(lines: string[], node: Record, isSession: boolean): void { + const groups = Object.entries(node).filter(([, v]) => typeof v === "object" && v !== null && !isRpcMethod(v)); + const topLevelMethods = Object.entries(node).filter(([, v]) => isRpcMethod(v)); + + const wrapperName = isSession ? "SessionRpc" : "ServerRpc"; + + // Emit API classes for groups + for (const [groupName, groupNode] of groups) { + const apiName = toPascalCase(groupName) + "Api"; + if (isSession) { + lines.push(`class ${apiName}:`); + lines.push(` def __init__(self, client: "JsonRpcClient", session_id: str):`); + lines.push(` self._client = client`); + lines.push(` self._session_id = session_id`); + } else { + lines.push(`class ${apiName}:`); + lines.push(` def __init__(self, client: "JsonRpcClient"):`); + lines.push(` self._client = client`); + } + lines.push(``); + for (const [key, value] of Object.entries(groupNode as Record)) { + if (!isRpcMethod(value)) continue; + emitMethod(lines, key, value, isSession); + } + lines.push(``); + } + + // Emit wrapper class + if (isSession) { + lines.push(`class ${wrapperName}:`); + lines.push(` """Typed session-scoped RPC methods."""`); + lines.push(` def __init__(self, client: "JsonRpcClient", session_id: str):`); + lines.push(` self._client = client`); + lines.push(` self._session_id = session_id`); + for (const [groupName] of groups) { + lines.push(` self.${toSnakeCase(groupName)} = ${toPascalCase(groupName)}Api(client, session_id)`); + } + } else { + lines.push(`class ${wrapperName}:`); + lines.push(` """Typed server-scoped RPC methods."""`); + lines.push(` def __init__(self, client: "JsonRpcClient"):`); + lines.push(` self._client = client`); + for (const [groupName] of groups) { + lines.push(` self.${toSnakeCase(groupName)} = ${toPascalCase(groupName)}Api(client)`); + } + } + lines.push(``); + + // Top-level methods + for (const [key, value] of topLevelMethods) { + if (!isRpcMethod(value)) continue; + emitMethod(lines, key, value, isSession); + } + lines.push(``); +} + +function emitMethod(lines: string[], name: string, method: RpcMethod, isSession: boolean): void { + const methodName = toSnakeCase(name); + const resultType = toPascalCase(method.rpcMethod) + "Result"; + + const paramProps = method.params?.properties || {}; + const nonSessionParams = Object.keys(paramProps).filter((k) => k !== "sessionId"); + const hasParams = isSession ? nonSessionParams.length > 0 : Object.keys(paramProps).length > 0; + const paramsType = toPascalCase(method.rpcMethod) + "Params"; + + // Build signature with typed params + const sig = hasParams + ? ` async def ${methodName}(self, params: ${paramsType}) -> ${resultType}:` + : ` async def ${methodName}(self) -> ${resultType}:`; + + lines.push(sig); + + // Build request body with proper serialization/deserialization + if (isSession) { + if (hasParams) { + lines.push(` params_dict = {k: v for k, v in params.to_dict().items() if v is not None}`); + lines.push(` params_dict["sessionId"] = self._session_id`); + lines.push(` return ${resultType}.from_dict(await self._client.request("${method.rpcMethod}", params_dict))`); + } else { + lines.push(` return ${resultType}.from_dict(await self._client.request("${method.rpcMethod}", {"sessionId": self._session_id}))`); + } + } else { + if (hasParams) { + lines.push(` params_dict = {k: v for k, v in params.to_dict().items() if v is not None}`); + lines.push(` return ${resultType}.from_dict(await self._client.request("${method.rpcMethod}", params_dict))`); + } else { + lines.push(` return ${resultType}.from_dict(await self._client.request("${method.rpcMethod}", {}))`); + } + } + lines.push(``); +} + +// ── Main ──────────────────────────────────────────────────────────────────── + +async function generate(sessionSchemaPath?: string, apiSchemaPath?: string): Promise { + await generateSessionEvents(sessionSchemaPath); + try { + await generateRpc(apiSchemaPath); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === "ENOENT" && !apiSchemaPath) { + console.log("Python: skipping RPC (api.schema.json not found)"); + } else { + throw err; + } + } +} + +const sessionArg = process.argv[2] || undefined; +const apiArg = process.argv[3] || undefined; +generate(sessionArg, apiArg).catch((err) => { + console.error("Python generation failed:", err); + process.exit(1); +}); diff --git a/scripts/codegen/typescript.ts b/scripts/codegen/typescript.ts new file mode 100644 index 00000000..77c31019 --- /dev/null +++ b/scripts/codegen/typescript.ts @@ -0,0 +1,194 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +/** + * TypeScript code generator for session-events and RPC types. + */ + +import fs from "fs/promises"; +import type { JSONSchema7 } from "json-schema"; +import { compile } from "json-schema-to-typescript"; +import { + getSessionEventsSchemaPath, + getApiSchemaPath, + postProcessSchema, + writeGeneratedFile, + isRpcMethod, + type ApiSchema, + type RpcMethod, +} from "./utils.js"; + +// ── Utilities ─────────────────────────────────────────────────────────────── + +function toPascalCase(s: string): string { + return s.charAt(0).toUpperCase() + s.slice(1); +} + +function collectRpcMethods(node: Record): RpcMethod[] { + const results: RpcMethod[] = []; + for (const value of Object.values(node)) { + if (isRpcMethod(value)) { + results.push(value); + } else if (typeof value === "object" && value !== null) { + results.push(...collectRpcMethods(value as Record)); + } + } + return results; +} + +// ── Session Events ────────────────────────────────────────────────────────── + +async function generateSessionEvents(schemaPath?: string): Promise { + console.log("TypeScript: generating session-events..."); + + const resolvedPath = schemaPath ?? (await getSessionEventsSchemaPath()); + const schema = JSON.parse(await fs.readFile(resolvedPath, "utf-8")) as JSONSchema7; + const processed = postProcessSchema(schema); + + const ts = await compile(processed, "SessionEvent", { + bannerComment: `/** + * AUTO-GENERATED FILE - DO NOT EDIT + * Generated from: session-events.schema.json + */`, + style: { semi: true, singleQuote: false, trailingComma: "all" }, + additionalProperties: false, + }); + + const outPath = await writeGeneratedFile("nodejs/src/generated/session-events.ts", ts); + console.log(` ✓ ${outPath}`); +} + +// ── RPC Types ─────────────────────────────────────────────────────────────── + +function resultTypeName(rpcMethod: string): string { + return rpcMethod.split(".").map(toPascalCase).join("") + "Result"; +} + +function paramsTypeName(rpcMethod: string): string { + return rpcMethod.split(".").map(toPascalCase).join("") + "Params"; +} + +async function generateRpc(schemaPath?: string): Promise { + console.log("TypeScript: generating RPC types..."); + + const resolvedPath = schemaPath ?? (await getApiSchemaPath()); + const schema = JSON.parse(await fs.readFile(resolvedPath, "utf-8")) as ApiSchema; + + const lines: string[] = []; + lines.push(`/** + * AUTO-GENERATED FILE - DO NOT EDIT + * Generated from: api.schema.json + */ + +import type { MessageConnection } from "vscode-jsonrpc/node.js"; +`); + + const allMethods = [...collectRpcMethods(schema.server || {}), ...collectRpcMethods(schema.session || {})]; + + for (const method of allMethods) { + const compiled = await compile(method.result, resultTypeName(method.rpcMethod), { + bannerComment: "", + additionalProperties: false, + }); + lines.push(compiled.trim()); + lines.push(""); + + if (method.params?.properties && Object.keys(method.params.properties).length > 0) { + const paramsCompiled = await compile(method.params, paramsTypeName(method.rpcMethod), { + bannerComment: "", + additionalProperties: false, + }); + lines.push(paramsCompiled.trim()); + lines.push(""); + } + } + + // Generate factory functions + if (schema.server) { + lines.push(`/** Create typed server-scoped RPC methods (no session required). */`); + lines.push(`export function createServerRpc(connection: MessageConnection) {`); + lines.push(` return {`); + lines.push(...emitGroup(schema.server, " ", false)); + lines.push(` };`); + lines.push(`}`); + lines.push(""); + } + + if (schema.session) { + lines.push(`/** Create typed session-scoped RPC methods. */`); + lines.push(`export function createSessionRpc(connection: MessageConnection, sessionId: string) {`); + lines.push(` return {`); + lines.push(...emitGroup(schema.session, " ", true)); + lines.push(` };`); + lines.push(`}`); + lines.push(""); + } + + const outPath = await writeGeneratedFile("nodejs/src/generated/rpc.ts", lines.join("\n")); + console.log(` ✓ ${outPath}`); +} + +function emitGroup(node: Record, indent: string, isSession: boolean): string[] { + const lines: string[] = []; + for (const [key, value] of Object.entries(node)) { + if (isRpcMethod(value)) { + const { rpcMethod, params } = value; + const resultType = resultTypeName(rpcMethod); + const paramsType = paramsTypeName(rpcMethod); + + const paramEntries = params?.properties ? Object.entries(params.properties).filter(([k]) => k !== "sessionId") : []; + const hasParams = params?.properties && Object.keys(params.properties).length > 0; + const hasNonSessionParams = paramEntries.length > 0; + + const sigParams: string[] = []; + let bodyArg: string; + + if (isSession) { + if (hasNonSessionParams) { + sigParams.push(`params: Omit<${paramsType}, "sessionId">`); + bodyArg = "{ sessionId, ...params }"; + } else { + bodyArg = "{ sessionId }"; + } + } else { + if (hasParams) { + sigParams.push(`params: ${paramsType}`); + bodyArg = "params"; + } else { + bodyArg = "{}"; + } + } + + lines.push(`${indent}${key}: async (${sigParams.join(", ")}): Promise<${resultType}> =>`); + lines.push(`${indent} connection.sendRequest("${rpcMethod}", ${bodyArg}),`); + } else if (typeof value === "object" && value !== null) { + lines.push(`${indent}${key}: {`); + lines.push(...emitGroup(value as Record, indent + " ", isSession)); + lines.push(`${indent}},`); + } + } + return lines; +} + +// ── Main ──────────────────────────────────────────────────────────────────── + +async function generate(sessionSchemaPath?: string, apiSchemaPath?: string): Promise { + await generateSessionEvents(sessionSchemaPath); + try { + await generateRpc(apiSchemaPath); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === "ENOENT" && !apiSchemaPath) { + console.log("TypeScript: skipping RPC (api.schema.json not found)"); + } else { + throw err; + } + } +} + +const sessionArg = process.argv[2] || undefined; +const apiArg = process.argv[3] || undefined; +generate(sessionArg, apiArg).catch((err) => { + console.error("TypeScript generation failed:", err); + process.exit(1); +}); diff --git a/scripts/codegen/utils.ts b/scripts/codegen/utils.ts new file mode 100644 index 00000000..88ca68de --- /dev/null +++ b/scripts/codegen/utils.ts @@ -0,0 +1,138 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +/** + * Shared utilities for code generation - schema loading, file I/O, schema processing. + */ + +import { execFile } from "child_process"; +import fs from "fs/promises"; +import path from "path"; +import { fileURLToPath } from "url"; +import { promisify } from "util"; +import type { JSONSchema7, JSONSchema7Definition } from "json-schema"; + +export const execFileAsync = promisify(execFile); + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +/** Root of the copilot-sdk repo */ +export const REPO_ROOT = path.resolve(__dirname, "../.."); + +/** Event types to exclude from generation (internal/legacy types) */ +export const EXCLUDED_EVENT_TYPES = new Set(["session.import_legacy"]); + +// ── Schema paths ──────────────────────────────────────────────────────────── + +export async function getSessionEventsSchemaPath(): Promise { + const schemaPath = path.join( + REPO_ROOT, + "nodejs/node_modules/@github/copilot/schemas/session-events.schema.json" + ); + await fs.access(schemaPath); + return schemaPath; +} + +export async function getApiSchemaPath(cliArg?: string): Promise { + if (cliArg) return cliArg; + const schemaPath = path.join( + REPO_ROOT, + "nodejs/node_modules/@github/copilot/schemas/api.schema.json" + ); + await fs.access(schemaPath); + return schemaPath; +} + +// ── Schema processing ─────────────────────────────────────────────────────── + +/** + * Post-process JSON Schema for quicktype compatibility. + * Converts boolean const values to enum, filters excluded event types. + */ +export function postProcessSchema(schema: JSONSchema7): JSONSchema7 { + if (typeof schema !== "object" || schema === null) return schema; + + const processed: JSONSchema7 = { ...schema }; + + if ("const" in processed && typeof processed.const === "boolean") { + processed.enum = [processed.const]; + delete processed.const; + } + + if (processed.properties) { + const newProps: Record = {}; + for (const [key, value] of Object.entries(processed.properties)) { + newProps[key] = typeof value === "object" ? postProcessSchema(value as JSONSchema7) : value; + } + processed.properties = newProps; + } + + if (processed.items) { + if (typeof processed.items === "object" && !Array.isArray(processed.items)) { + processed.items = postProcessSchema(processed.items as JSONSchema7); + } else if (Array.isArray(processed.items)) { + processed.items = processed.items.map((item) => + typeof item === "object" ? postProcessSchema(item as JSONSchema7) : item + ) as JSONSchema7Definition[]; + } + } + + for (const combiner of ["anyOf", "allOf", "oneOf"] as const) { + if (processed[combiner]) { + processed[combiner] = processed[combiner]! + .filter((item) => { + if (typeof item !== "object") return true; + const typeConst = (item as JSONSchema7).properties?.type; + if (typeof typeConst === "object" && "const" in typeConst) { + return !EXCLUDED_EVENT_TYPES.has(typeConst.const as string); + } + return true; + }) + .map((item) => + typeof item === "object" ? postProcessSchema(item as JSONSchema7) : item + ) as JSONSchema7Definition[]; + } + } + + if (processed.definitions) { + const newDefs: Record = {}; + for (const [key, value] of Object.entries(processed.definitions)) { + newDefs[key] = typeof value === "object" ? postProcessSchema(value as JSONSchema7) : value; + } + processed.definitions = newDefs; + } + + if (typeof processed.additionalProperties === "object") { + processed.additionalProperties = postProcessSchema(processed.additionalProperties as JSONSchema7); + } + + return processed; +} + +// ── File output ───────────────────────────────────────────────────────────── + +export async function writeGeneratedFile(relativePath: string, content: string): Promise { + const fullPath = path.join(REPO_ROOT, relativePath); + await fs.mkdir(path.dirname(fullPath), { recursive: true }); + await fs.writeFile(fullPath, content, "utf-8"); + return fullPath; +} + +// ── RPC schema types ──────────────────────────────────────────────────────── + +export interface RpcMethod { + rpcMethod: string; + params: JSONSchema7 | null; + result: JSONSchema7; +} + +export interface ApiSchema { + server?: Record; + session?: Record; +} + +export function isRpcMethod(node: unknown): node is RpcMethod { + return typeof node === "object" && node !== null && "rpcMethod" in node; +}