Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.10" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.10" />
<PackageVersion Include="Microsoft.Extensions.Hosting" Version="10.0.10" />
<PackageVersion Include="Microsoft.Extensions.Http" Version="10.0.10" />
<PackageVersion Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.10" />
<PackageVersion Include="Microsoft.Extensions.Logging" Version="10.0.10" />
<PackageVersion Include="Microsoft.SourceLink.GitHub" Version="10.0.301" />
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ roadmap, and [`docs/research/`](docs/research/) for the research notes behind th
| Package | Description |
| --- | --- |
| `OpenCode.Sdk` | Core: typed HTTP client, SSE event streaming, `opencode serve` launcher |
| `OpenCode.Sdk.Extensions` | DI integration: `AddOpenCodeClient()`, `IHttpClientFactory` wiring, options binding |
| `OpenCode.Sdk.Extensions` | DI integration: `AddOpenCode()`, singleton client family, options binding |

## License

Expand Down
16 changes: 9 additions & 7 deletions docs/ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,15 +61,17 @@ enrichments. PR #26 is merged. The #34 contract-test consolidation landed as its
follow-up PR: one shared contract scaffold (`ContractScenario`, `WireBodyData`), a single
`FixtureLoader`/`RecordingHttpHandler` pair compiled from `tests/Shared` into both the SDK
and Extensions test projects, and the previously unobserved object-envelope
`{"data":null}` refusal pinned by test.
`{"data":null}` refusal pinned by test (the support pair under `tests/Shared` is consumed
by the SDK test project; the Extensions tests are registration-topology only).

**M3 is open** (plan: `superpowers/plans/2026-08-15-m3-plan.md`; decisions: research log
Session 24). Q92 sealed simplicity-first construction (ADR-0010): the transport
constructor internalizes, Extensions goes factory-less singleton, and the Q91 guard
machinery deletes — executing as the reshape PR that opens the M3 runway and closes #31
by construction. #28, #32, and #33 are sealed with their execution homes recorded
(Session 24); the location + merged-Request design session's marshalling seals are still
pending. Remaining M2 breadth batches interleave after the reshape lands.
Session 24). Q92's simplicity-first construction (ADR-0010) is landed — Arc 1 complete:
the transport constructor is internal friend-assembly test surface, Extensions registers
a factory-less singleton client family with a roster contract test, and the Q91 guard
machinery is deleted — closing #31 by construction. #28, #32, and #33 are sealed with
their execution homes recorded (Session 24); the location + merged-Request design
session's marshalling seals (Arc 2) are still pending. Remaining M2 breadth batches
interleave next, each against the marshalling design once it seals.

## Milestones

Expand Down
16 changes: 8 additions & 8 deletions docs/superpowers/plans/2026-08-15-m3-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,20 +11,20 @@ auto-reconnect), doc 15 §5a/§6 (placement census), research log Sessions 22 (Q

## Arc 1 — Construction & transport reshape (Q92, ADR-0010; own PR, first on the runway)

- [ ] The `(HttpClient, options)` constructor internalizes; IVT covers the repository's
- [x] The `(HttpClient, options)` constructor internalizes; IVT covers the repository's
tests and benchmarks (contract tests and `ContractScenario` keep compiling
unchanged).
- [ ] `Pipeline` always owns its transport: `_ownsHttpClient` and the borrowed-path
disposal branch retire; the anonymous-mode `Authorization` refusal and injected
`BaseAddress` refusal delete with their tests (the Q90 password semantics — `null`
anonymous, blank refused — stay pinned).
- [ ] Extensions rebuilds factory-less: `AddOpenCode` registers one singleton root client
- [x] `Pipeline` keeps its internal ownership flag for the injected test path; the
anonymous-mode `Authorization` refusal and injected `BaseAddress` refusal delete
with their tests (the Q90 password semantics — `null` anonymous, blank refused —
stay pinned).
- [x] Extensions rebuilds factory-less: `AddOpenCode` registers one singleton root client
over the owned transport, sub-clients resolve from that same instance, the return
type becomes `IServiceCollection`, and `Microsoft.Extensions.Http` leaves the
dependency set.
- [ ] The roster contract test (from #31): every generated client family resolves through
- [x] The roster contract test (from #31): every generated client family resolves through
DI; the next family cannot silently lag the registration list.
- [ ] PublicApi baseline re-review (constructor removal, Extensions signature) and the
- [x] PublicApi baseline re-review (constructor removal, Extensions signature) and the
sandbox showcase update. Closes #31.

## Arc 2 — Location + merged-Request design session (seals before any body+query admission)
Expand Down
15 changes: 2 additions & 13 deletions src/OpenCode.Sdk.Extensions/OpenCode.Sdk.Extensions.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -2,29 +2,18 @@

<PropertyGroup>
<TargetFrameworks>$(AllTargetFrameworks)</TargetFrameworks>
<Description>Dependency injection and IHttpClientFactory integration for the unofficial opencode .NET SDK.</Description>
<PackageTags>opencode;sdk;dependency-injection;httpclientfactory;options</PackageTags>
<Description>Dependency injection integration for the unofficial opencode .NET SDK.</Description>
<PackageTags>opencode;sdk;dependency-injection;options</PackageTags>
<IsAotCompatible Condition="'$(TargetFramework)' == 'net10.0'">true</IsAotCompatible>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Http" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="../OpenCode.Sdk/OpenCode.Sdk.csproj"/>
</ItemGroup>

<!-- .NET Framework ships HttpClient in this framework assembly; no NuGet HTTP stack is added. -->
<ItemGroup Condition="'$(TargetFramework)' == 'net472'">
<Reference Include="System.Net.Http" />
</ItemGroup>

<!-- Implicit usings apply only on net6.0+; downlevel targets need these globally too. -->
<ItemGroup Condition="'$(TargetFramework)' == 'net472' Or '$(TargetFramework)' == 'netstandard2.0'">
<Using Include="System.Net.Http" />
</ItemGroup>

</Project>
Original file line number Diff line number Diff line change
Expand Up @@ -6,19 +6,17 @@
namespace Microsoft.Extensions.DependencyInjection;

/// <summary>
/// Registers the opencode client family over <c>IHttpClientFactory</c>: a typed
/// <see cref="OpenCodeClient"/> riding factory-managed transport, plus every sub-client
/// resolved from it so a consumer can inject <see cref="SessionsClient"/> directly. The
/// returned <see cref="IHttpClientBuilder"/> is the composition seam — chain
/// <c>AddHttpMessageHandler</c>, resilience handlers, or telemetry instrumentation onto it.
/// Registers the opencode client family as singletons: one <see cref="OpenCodeClient"/>
/// owning its transport for the container's lifetime, plus every sub-client resolved from
/// that same instance so a consumer can inject <see cref="SessionsClient"/> directly.
/// </summary>
public static class OpenCodeServiceCollectionExtensions
{
/// <summary>Registers the opencode client, shaping its options in code.</summary>
/// <param name="services">The service collection to register with.</param>
/// <param name="configure">Shapes the client options; the endpoint is required.</param>
/// <returns>The typed-client builder for transport composition.</returns>
public static IHttpClientBuilder AddOpenCode(this IServiceCollection services, Action<OpenCodeClientOptions> configure)
/// <returns>The service collection for chaining.</returns>
public static IServiceCollection AddOpenCode(this IServiceCollection services, Action<OpenCodeClientOptions> configure)
{
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(configure);
Expand All @@ -30,10 +28,10 @@ public static IHttpClientBuilder AddOpenCode(this IServiceCollection services, A
/// <summary>Registers the opencode client, binding its options from configuration.</summary>
/// <param name="services">The service collection to register with.</param>
/// <param name="configuration">The configuration section carrying the client options.</param>
/// <returns>The typed-client builder for transport composition.</returns>
/// <returns>The service collection for chaining.</returns>
[RequiresDynamicCode("Configuration binding uses reflection over the options type; prefer the configure-action overload on native AOT.")]
[RequiresUnreferencedCode("Configuration binding may require members trimming removes; prefer the configure-action overload when trimming.")]
public static IHttpClientBuilder AddOpenCode(this IServiceCollection services, IConfiguration configuration)
public static IServiceCollection AddOpenCode(this IServiceCollection services, IConfiguration configuration)
{
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(configuration);
Expand All @@ -42,15 +40,15 @@ public static IHttpClientBuilder AddOpenCode(this IServiceCollection services, I
return AddOpenCodeCore(services);
}

private static IHttpClientBuilder AddOpenCodeCore(IServiceCollection services)
private static IServiceCollection AddOpenCodeCore(IServiceCollection services)
{
// A named factory client bound as the typed client: the factory owns the HttpClient
// and its handler rotation, so the client registers transient and disposes nothing.
var builder = services.AddHttpClient(nameof(OpenCodeClient))
.AddTypedClient(static (httpClient, provider) => new OpenCodeClient(
httpClient,
provider.GetRequiredService<IOptions<OpenCodeClientOptions>>().Value));
_ = services.AddTransient(static SessionsClient (provider) => provider.GetRequiredService<OpenCodeClient>().Sessions);
return builder;
// One singleton client owns the transport for the container's lifetime (pooled
// connection lifetime keeps it healthy on modern TFMs); sub-clients resolve from
// that same instance, so every injection shares one pipeline and the container
// disposes one client at shutdown.
_ = services.AddSingleton(static provider => new OpenCodeClient(
provider.GetRequiredService<IOptions<OpenCodeClientOptions>>().Value));
_ = services.AddSingleton(static SessionsClient (provider) => provider.GetRequiredService<OpenCodeClient>().Sessions);
return services;
}
}
46 changes: 0 additions & 46 deletions src/OpenCode.Sdk/Internal/Pipeline.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ internal sealed class Pipeline : IDisposable
{
private readonly AuthenticationHeaderValue? _authorization;
private readonly string _endpointBase;
private readonly bool _enforceAnonymousDefaults;
private readonly HttpClient _httpClient;
private readonly bool _ownsHttpClient;
private readonly ProductInfoHeaderValue _userAgent;
Expand Down Expand Up @@ -47,29 +46,6 @@ internal Pipeline(HttpClient httpClient, bool ownsHttpClient, IOpenCodeClientOpt
nameof(options));
}

// The pipeline builds every request URI absolute from Endpoint, so a BaseAddress on
// an injected client would never be consulted; refuse the conflict instead of
// silently ignoring half the caller's configuration.
if (!ownsHttpClient && httpClient.BaseAddress is not null)
{
throw new ArgumentException(
"The supplied HttpClient carries a BaseAddress, which this SDK never consults — requests are " +
"built absolute from OpenCodeClientOptions.Endpoint. Remove the BaseAddress or change the Endpoint.",
nameof(httpClient));
}

// Anonymous mode cannot be expressed by omission over an injected client: HttpClient
// copies its default headers onto every request that lacks the header, so a default
// Authorization would ride requests this SDK promises are anonymous. Refuse the
// conflict instead of silently sending a foreign credential.
if (!ownsHttpClient && password is null && CarriesDefaultAuthorization(httpClient))
{
throw new ArgumentException(
"The options request anonymous requests (Password is null), but the supplied HttpClient's " +
"default headers carry an Authorization header. Remove the default header or set a password.",
nameof(httpClient));
}

_httpClient = httpClient;
_ownsHttpClient = ownsHttpClient;
_endpointBase = EndpointPolicy.Normalize(endpoint);
Expand All @@ -80,11 +56,6 @@ internal Pipeline(HttpClient httpClient, bool ownsHttpClient, IOpenCodeClientOpt
_authorization = password is null
? null
: new AuthenticationHeaderValue("Basic", Convert.ToBase64String(Encoding.UTF8.GetBytes($"{username}:{password}")));

// Default headers stay legally mutable after construction, so the anonymous-mode
// refusal must also run per send; owned transports never expose their client and
// authenticated sends override the default, so only anonymous injected clients pay.
_enforceAnonymousDefaults = !ownsHttpClient && _authorization is null;
}

public static Pipeline Create(OpenCodeClientOptions options)
Expand Down Expand Up @@ -155,9 +126,6 @@ public void Dispose()
}
}

/// <summary>Presence beats parseability: a raw unparseable default value still rides the wire.</summary>
private static bool CarriesDefaultAuthorization(HttpClient httpClient) => httpClient.DefaultRequestHeaders.TryGetValues("Authorization", out _);

/// <summary>The owned transport: pooled connection lifetime keeps DNS rotation alive on modern TFMs.</summary>
private static HttpClient CreateOwnedHttpClient()
{
Expand Down Expand Up @@ -206,20 +174,6 @@ private async Task<TResponse> ExecuteCoreAsync<TBody, TResponse>(HttpMethod meth
throw new ArgumentOutOfRangeException(nameof(options), errorBehavior, "Unknown ErrorBehavior value.");
}

if (_enforceAnonymousDefaults && CarriesDefaultAuthorization(_httpClient))
{
throw new OpenCodeTransportException(
"The anonymous opencode client's HttpClient carries a default Authorization header; the request " +
"was refused before sending. Remove the default header or set a password.");
}

if (!_ownsHttpClient && _httpClient.BaseAddress is not null)
{
throw new OpenCodeTransportException(
"The injected HttpClient's BaseAddress is set, and this SDK never consults it — requests are " +
"built absolute from the configured Endpoint. The request was refused before sending.");
}

int status;
string rawBody;
using (var request = new HttpRequestMessage(method, new Uri(_endpointBase + route, UriKind.Absolute)))
Expand Down
6 changes: 3 additions & 3 deletions src/OpenCode.Sdk/OpenCodeClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,11 @@ public OpenCodeClient(OpenCodeClientOptions options)
}

/// <summary>
/// Initializes a client over a caller-owned HttpClient; the SDK never disposes it. With a password set, the SDK&apos;s per-request Authorization header overrides any client default; in anonymous mode a default Authorization header is refused at construction and before every send.
/// Initializes a client over an injected transport the client never disposes; friend-assembly test surface.
/// </summary>
/// <param name = "httpClient">The caller-owned HTTP client.</param>
/// <param name = "httpClient">The injected HTTP client.</param>
/// <param name = "options">The client options; the endpoint is required.</param>
public OpenCodeClient(HttpClient httpClient, OpenCodeClientOptions options)
internal OpenCodeClient(HttpClient httpClient, OpenCodeClientOptions options)
{
_pipeline = Pipeline.Create(httpClient, options);
_sessions = new SessionsClient(_pipeline);
Expand Down
7 changes: 1 addition & 6 deletions src/OpenCode.Sdk/OpenCodeClientOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,7 @@ public sealed class OpenCodeClientOptions : IOpenCodeClientOptions
/// Gets or sets the HTTP basic-authentication password. <see langword="null"/> sends
/// anonymous requests — a server without authentication configured expects none. An
/// empty or whitespace value is refused with <see cref="ArgumentException"/> at client
/// construction; the SDK never reads credentials from the environment. On a
/// caller-owned <see cref="HttpClient"/> the SDK's per-request <c>Authorization</c>
/// header overrides any client default when a password is set; in anonymous mode a
/// default <c>Authorization</c> header is refused fail-closed at construction and
/// before every send, because the transport would otherwise attach that foreign
/// credential to requests this SDK promises are anonymous.
/// construction; the SDK never reads credentials from the environment.
/// </summary>
public string? Password { get; set; }
}

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -22,24 +22,9 @@
<ProjectReference Include="../../src/OpenCode.Sdk.Extensions/OpenCode.Sdk.Extensions.csproj"/>
</ItemGroup>

<!-- Cross-project test support compiles into each consuming test project as shared source. -->
<ItemGroup>
<Compile Include="../Shared/**/*.cs" LinkBase="Shared" />
</ItemGroup>

<!-- .NET Framework ships HttpClient in this framework assembly; no NuGet HTTP stack is added. -->
<ItemGroup Condition="'$(TargetFramework)' == 'net472'">
<Reference Include="System.Net.Http" />
</ItemGroup>

<!-- Implicit usings apply only on net6.0+; the net472 leg needs these globally too. -->
<ItemGroup Condition="'$(TargetFramework)' == 'net472'">
<Using Include="System.Linq" />
<Using Include="System.Net.Http" />
</ItemGroup>

<ItemGroup>
<EmbeddedResource Include="Fixtures/**/*.json" />
</ItemGroup>

</Project>
Loading