[Builder's Log] Cadenza — .NET 10 file-based apps를 위한 단일 파일 SDK

안녕하세요, 남정현입니다.

File-based App에 대한 개인적인 연구 결과들을 종합한 또 다른 커뮤니티 프로젝트인 Cadenza를 기획했고, 실제 테스트를 거쳐 v1.0.7까지 다듬어 공개합니다.

Cadenza가 무엇인가

.cs 파일 한 장이 곧 프로그램이 되는 .NET 10의 file-based apps 모드를 ergonomic하게 만들어주는 custom MSBuild SDK 가족입니다. 4개 변종을 같은 원리로 묶어 두었습니다:

SDK Use case 기반
Cadenza 콘솔 스크립트, CLI 유틸리티 Microsoft.NET.Sdk
Cadenza.Worker 백그라운드 서비스, 데몬 Microsoft.NET.Sdk.Worker
Cadenza.Web 웹 API, Minimal API 스크립트 Microsoft.NET.Sdk.Web
Cadenza.Mcp MCP 서버 (Claude Desktop · Cursor · Cline) 공식 ModelContextProtocol SDK

가장 짧은 예시 — 마크다운 파일 크기 출력:

#!/usr/bin/env dotnet run
#:sdk Cadenza@1.0.7

foreach (var file in Glob("**/*.md"))
    WriteLine($"{file}: {ReadText(file).Length:N0} bytes");

Glob, ReadText, WriteLine, Run 등이 global using static을 통해 접두사 없이 호출됩니다. .cs 한 파일로 콘솔 도구 한 개가 완성되고, 그 자체로 publish하면 단일 자체 포함 바이너리가 됩니다.

왜 만들었는가

.NET 10이 file-based apps라는 기능을 가져왔습니다. dotnet run app.cs 한 줄로 csproj 없이 .cs 파일을 실행할 수 있게 됐죠. 그런데 막상 써보니 capability는 SDK에 있는데 ergonomics가 비어 있다는 인상이 강했습니다. 여전히 using 문, PackageReference, 호스트 부트스트랩을 사용자가 매번 직접 써야 했습니다.

Cadenza는 그 자리를 채웁니다. SDK 안에 wiring을 미리 박아두고, 사용자는 #:sdk Cadenza@1.0.7 한 줄과 비즈니스 로직만 작성합니다. 4개 변종이 같은 패턴을 공유하므로 학습 비용도 한 번이면 됩니다.

이름의 배경 — 카덴차(cadenza)는 협주곡 중 오케스트라가 멎고 독주자가 홀로 연주하는 구간입니다. 솔로지만 완성된 음악이고, 협주곡과 대립이 아니라 같은 음악의 다른 부분이죠. 한 파일 .cs 스크립트가 .NET이라는 음악의 카덴차 — csproj 기반 .NET을 부정하지 않으면서 .NET의 다른 모드로 존재한다는 의미를 담았습니다. C#이 이미 #(sharp) 음악 기호로 이름지어진 언어라는 점도 자연스럽게 맞물립니다.

시작하기

.NET 10 SDK 10.0.300 이상이 필요합니다.

# 1) 빈 디렉토리에서 hello.cs 작성
cat > hello.cs << 'EOF'
#!/usr/bin/env dotnet run
#:sdk Cadenza@1.0.7

WriteLine("Hello, Cadenza!");
EOF

# 2) 실행
dotnet run hello.cs

# 3) 단일 바이너리 배포 (선택)
dotnet publish hello.cs -r linux-x64 -c Release

VS Code + C# Dev Kit 환경에서도 정상 동작하는 것을 확인했습니다. 자세한 사용법과 변종별 tier 1 API는 GitHub READMEspec 문서에서 확인하실 수 있습니다.

한 가지 주의점

#:sdk 지시자의 버전은 정확한 SemVer만 허용됩니다. Cadenza@1.* 같은 floating 패턴은 MSBuild SDK resolver가 평가하지 못합니다 — restore 이전 단계에서 SDK가 결정되어야 하기 때문이고, PackageReference와 다른 자리입니다. 새 릴리스로 이동할 때는 #:sdk 줄을 직접 갱신하거나, 같은 디렉토리의 global.jsonmsbuild-sdks에 버전을 중앙화하는 방식이 있습니다. README의 troubleshooting 섹션에 자세히 적어두었습니다.

4 Likes

4개 변종 빠른 투어

워커 — 주기적 작업:

#!/usr/bin/env dotnet run
#:sdk Cadenza.Worker@1.0.7

await Run(async (ct) =>
{
    while (!ct.IsCancellationRequested)
    {
        Log.Info($"Heartbeat at {DateTime.UtcNow:O}");
        await Task.Delay(TimeSpan.FromSeconds(30), ct);
    }
});

— Minimal API:

#!/usr/bin/env dotnet run
#:sdk Cadenza.Web@1.0.7

Get("/", () => "Hello from Cadenza.Web");
Get("/health", () => new { status = "ok", time = DateTime.UtcNow });

await Run();

MCP 서버 — Claude Desktop에 연결할 도구:

#!/usr/bin/env dotnet run
#:sdk Cadenza.Mcp@1.0.7

Tool("read_file", "Read a UTF-8 text file from disk",
    (string path) => ReadText(path));

Tool("list_files", "List files matching a glob pattern",
    (string pattern) => Glob(pattern).ToArray());

await Run();

Cadenza.Mcp는 공식 ModelContextProtocol C# SDK 위의 thin ergonomic layer입니다. 프로토콜 책임·전송·JSON-RPC는 모두 외부 SDK에 위임하고, file-based scripting의 ergonomics만 Cadenza가 담당합니다. stdio 전송에서 stdout 오염을 막기 위해 WriteLine이 의도적으로 tier 1에서 빠져 있고, 진단 출력은 Log.* (stderr)로 통일되어 있습니다.

단일 바이너리 배포

Iteration은 dotnet run app.cs로 끝나지만, distribution은 dotnet publish가 SDK default로 R2R + SingleFile + SCD를 적용합니다:

dotnet publish app.cs -r linux-x64 -c Release
# → ~60-80MB 단일 자체 포함 바이너리

압축 활성화 시 ~30-40MB, #:property PublishAot=true로 NativeAOT를 켜면 ~10-30MB. Go-like 단일 바이너리 배포 모델을 .NET에서 한 줄 명령으로 얻는 자리가 핵심 디자인 의도입니다.

2 Likes

로드맵

다음으로 검토 중인 변종은 Cadenza.Wasi — WASI 모듈(서버사이드 wasm) 단일 파일 빌드입니다. wasm-tools workload가 “experimental” 라벨을 떼는 시점이 진입 트리거입니다. WASI 2.0이 2026 Q1에 production-ready 단계에 들어선 만큼, .NET에서 single-.cs → single-.wasm 경로가 매끄럽게 열리면 엣지 컴퓨팅 배포 시나리오에 자연스럽게 맞을 것으로 보고 있습니다.

그 외 후보 — Cadenza.Aspire, Cadenza.Lambda, Cadenza.Function, Cadenza.MewUI(SwiftUI-style 데스크톱, MewUI 1.0 도달 후) — 가 v0.3+ 로드맵에 정리되어 있습니다.

피드백 환영

이슈, PR, 사용기 모두 환영합니다. 특히 다음 두 가지 피드백이 우선순위가 높습니다:

  • 실제 사용 시 마찰점 — README에 적힌 troubleshooting 외에 발견하신 케이스

  • tier 1 API에서 빠진 것 같은 기능 — v1.x 동안은 tier 1이 frozen이지만, v2.0 격상 시 우선 검토 대상이 됩니다

GitHub Issues에 자유롭게 올려주시거나 이 글에 댓글로 남겨주셔도 좋습니다.

2 Likes

이틀 만에 가족이 하나 더 늘었습니다. 위 로드맵에는 적지 않았던 다섯 번째 변종 Cadenza.Agent 입니다.

SDK Use case 기반
Cadenza.Agent LLM 에이전트 서버 (Chat Completion + Responses 호환) Microsoft.NET.Sdk.Web + Microsoft.Extensions.AI

왜 추가됐는가

OpenAI Codex CLI가 2026년 2월부로 Chat Completion 지원을 끊고 Responses API에만 묶였습니다. shell tool, apply_patch, plan tracking을 갖춘 좋은 에이전트 UX이지만, Chat Completion만 지원하는 Ollama·LM Studio·로컬 Llama runner 입장에서는 그대로 막힌 상태가 됩니다.

다행히 Codex 설정에는 탈출구가 하나 있습니다. model_provider config 블록 + wire_api = "responses" 조합으로, Responses 포맷을 말하는 어떤 서버와도 대화하게 만들 수 있습니다. 그렇다면 그 서버를 file-based 스크립트 한 장으로 띄울 수 있어야 한다는 게 자연스러운 결론이었고, 위에서 풀어 놓은 Cadenza 가족의 기존 패턴과도 정확히 맞물리는 자리였습니다.

Cadenza.Agent가 노출하는 엔드포인트는 두 개입니다.

  • POST /v1/chat/completions — Aider · Continue · Cursor · Copilot BYOK · sgpt

  • POST /v1/responses — Codex CLI

둘 다 사용자가 구성한 같은 IChatClient로 백킹됩니다. 백엔드를 OpenRouter·Ollama·OpenAI·Anthropic·Azure OpenAI 어느 것으로 향하게 해도 wire 포맷은 그대로 유지됩니다.

Codex CLI가 Claude 위에서 도는 최단 예시

OpenRouter를 백엔드로 두고 Codex를 Claude 3.5 Sonnet에 연결한 예시입니다(cadenza-catalog.json 자동 생성과 CODEX_HOME 격리 처리는 생략, 핵심 wiring만 추려서):

#!/usr/bin/env dotnet run
#:sdk Cadenza.Agent@1.0.14

using System.ClientModel;
using OpenAI;

var apiKey = Env.Get("OPENROUTER_API_KEY")
    ?? throw new InvalidOperationException("OPENROUTER_API_KEY env var missing");
var model = Env.Get("OPENROUTER_MODEL") ?? "anthropic/claude-3.5-sonnet";

var options = new OpenAIClientOptions { Endpoint = new Uri("https://openrouter.ai/api/v1") };
var chatClient = new OpenAI.Chat.ChatClient(model, new ApiKeyCredential(apiKey), options)
    .AsIChatClient();

UseChatClient(chatClient);
await Run();

이렇게 띄운 서버를 향해 Codex의 CODEX_HOME을 가리키면, OpenAI Codex CLI가 Anthropic Claude 3.5 Sonnet 위에서 그대로 동작합니다(또는 OpenRouter가 프론팅하는 수백 개 모델 어느 것 위에서든). UseChatClientUseOllama로 한 줄만 바꾸면 로컬 Ollama 모델로 옮기는 것도 가능합니다.

내부에서 일어나는 일

POST /v1/responses가 들어오면 에이전트의 처리 흐름은 대략 다음과 같습니다.

  1. Codex가 보낸 message / function_call / function_call_output 배열을 Microsoft.Extensions.AIIList<ChatMessage> 형태로 평탄화

  2. previous_response_id로 들어오는 턴 체이닝을 bounded in-memory dictionary로 재구성

  3. Codex의 shell·apply_patch·update_plan을 raw 스키마 그대로, JSON 스키마만 있고 실제 핸들러는 없는 PassthroughFunction으로 모델에 선언. 모델이 내뱉는 function_call은 그대로 Codex로 stream

  4. IChatClient.GetStreamingResponseAsync 호출 — 구성된 백엔드(OpenRouter·Ollama·OpenAI·Anthropic·Azure OpenAI)로 디스패치

  5. ChatResponseUpdate 스트림을 Codex가 기대하는 ~15종의 Responses SSE 이벤트 타입 (response.created, response.in_progress, response.output_item.added, response.output_text.delta, response.function_call_arguments.delta, response.completed, …) 으로 번역해서 emit

이 composability의 핵심은 IChatClient 추상화입니다. Cadenza.Agent는 OpenRouter가 사실 한 번은 Claude, 다음번은 Llama라는 사실에 관심이 없습니다. chat client 하나를 받아서 호출하고, 돌아온 걸 Codex가 원하는 wire 포맷으로 직렬화할 뿐입니다.

전체 코드(catalog JSON 자동 생성과 CODEX_HOME 출력 포함)는 풀 라이트업에 있습니다 → devwrite.ai/ko/posts/cadenza-agent

작업 중에 적어둔 메모 두 가지

  • CODEX_HOME 격리 패턴 — 사용자의 글로벌 ~/.codex/config.toml을 건드리지 않고 샘플별로 독립 디렉터리를 생성·자동 출력해 주는 패턴. Ollama용·OpenRouter용·gpt-5 튜닝용 샘플을 10개 둬도 서로 안 부딪힙니다. 동료에게 설정을 공유할 때도 .cs 파일 하나만 건네면, 동료의 codex 명령이 그 스크립트가 생성한 로컬 디렉터리를 가리키게 됩니다.

  • UTF-8 BOM footgun — .NET의 Encoding.UTF8은 BOM-emitting variant이라 File.WriteAllText(path, text, Encoding.UTF8)EF BB BF를 prepend합니다. Rust serde_json(Codex가 쓰는 라이브러리)은 RFC 8259를 엄격히 준수해서 이걸 거부합니다. new UTF8Encoding(encoderShouldEmitUTF8Identifier: false)로 고쳤고, 이 수정을 포함해 SDK를 1.0.14로 출시했습니다. 본인 코드에서 같은 패턴이 있다면 점검해 보실 만합니다.

로드맵에서의 위치

위 글에서 다음 검토 대상으로 적은 건 Cadenza.Wasi였고, 그 외 후보로 Cadenza.Aspire·Cadenza.Lambda·Cadenza.Function·Cadenza.MewUI를 적어두었습니다. Cadenza.Agent는 그 사이에 끼어들어 온 변종입니다. 사변적 후보로 먼저 적어두고 사용 사례를 찾아간 케이스가 아니라, Codex CLI를 쓰다 마주한 구체적 lock-in 한 건이 트리거가 돼서 SDK 변종이 따라온 케이스입니다. 적어도 저에게는 — Cadenza 가족이 채워나가는 자리에 대한 자신감을 한 단계 더 주는 사례였습니다. 같은 흐름으로 추가될 변종이 더 있을 수 있습니다.

피드백 환영

특히 다음 두 가지 케이스에 대한 사용기가 궁금합니다.

  • Codex CLI를 로컬 fine-tuned 모델 + 오프라인 fallback 조합으로 돌려보신 분의 경험

  • /v1/chat/completions 측 엔드포인트를 Aider · Continue · Cursor BYOK · sgpt 와 함께 써보신 분의 사용기

GitHub Issues에 자유롭게 올려주시거나 이 답글에 댓글로 남겨주셔도 좋습니다.