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
14 changes: 11 additions & 3 deletions .claude/skills/csharp-eval/SKILL.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
name: csharp-eval
description: Run / execute C# snippets non-interactively with the csharprepl CLI to observe real runtime behavior — return values, exceptions, serialized output — and to probe how a NuGet package actually behaves when called. The complement to dotnet-inspect; that tool inspects static API surface without executing; this one runs code. Use whenever you need to know what C# *does*, not just what an API *looks like*.
description: Run / execute C# snippets non-interactively with the csharprepl CLI to observe real runtime behavior — return values, exceptions, serialized output — and to probe how a NuGet package actually behaves when called. The complement to dotnet-inspect; that tool inspects static API surface without executing; this one runs code. Use whenever you need to know what C# *does*, not just what an API *looks like*. (To evaluate C# *inside* a running process and read its live state, see the csharprepl-inspect skill.)
---

# csharp-eval
Expand Down Expand Up @@ -72,10 +72,18 @@ csharprepl -e 'JsonConvert.SerializeObject(new[] { 1, 2, 3 })' -r 'nuget: Newton
- The evaluation **result** is the last thing on stdout. The first time a package is referenced, NuGet
prints a few restore-progress lines before it; cached runs print just the result.

## Evaluating inside a running process

The same CLI can attach to a *separate, already-running* .NET process and evaluate C# **inside it**, against
its live state (`csharprepl inspect <pid>`). That's a distinct workflow (the target must be launched with the
inspector enabled, state persists across calls, and you can detour live methods) with its own safety caveats
— see the **csharprepl-inspect** skill.

## Gotchas

- **No state across calls.** Each invocation is a fresh process — variables, `using`s, and references
do not carry over between runs. Make every snippet self-contained (include its own `#r` / `using`).
- **No state across calls.** Each invocation is a fresh process — variables, `using`s, and references do not
carry over between runs. Make every snippet self-contained (include its own `#r` / `using`). (Inspect mode
is the exception — see the csharprepl-inspect skill.)
- **First restore is slow.** The first time a package is referenced it's downloaded; later runs are
fast (cached under `~/.csharprepl/packages`).
- **Errors go to stderr with a nonzero exit code.** Compilation and runtime errors are written to
Expand Down
95 changes: 95 additions & 0 deletions .claude/skills/csharprepl-inspect/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
---
name: csharprepl-inspect
description: Attach to a running, inspector-enabled .NET process with `csharprepl inspect <pid>` and evaluate C# *inside* it — read and modify its live objects, statics, and DI services, and detour live methods (#replace/#wrap). Use to debug or probe a real running app's in-memory state, not static API surface (that's dotnet-inspect) or a throwaway snippet in a fresh process (that's csharp-eval). Dev/diagnostics only — code runs with the target's full privileges, never point it at production.
---

# csharprepl-inspect

Evaluate C# **inside a separate, already-running .NET process** and see/modify its live state. `csharprepl`
injects a real Roslyn engine into a target you launched with the inspector enabled; you then send code
non-interactively (same flags and output as the local REPL) and it runs in that process against its actual
in-memory objects.

## When to use this vs. csharp-eval vs. dotnet-inspect

- **"What's the live state of my running app?"** / **"Change a method's behavior in the running process"**
→ **this skill** (`csharprepl inspect <pid>`). Code runs *inside* the target.
- **"What does this code do?"** (a self-contained snippet, fresh throwaway process) → **csharp-eval**.
- **"What does this API look like?"** (signatures, members, docs — no execution) → **dotnet-inspect**.

The eval mechanics here — `-e` / `--eval-file`, piped stdin, quoting, `-r "nuget: ..."`, the clean-stdout /
errors-to-stderr / nonzero-exit contract — are **identical to csharp-eval**; see that skill for those
details. This skill covers only what's different about attaching to a live process.

## ⚠️ Safety

Evaluated code runs with the **target process's full privileges** — it's RCE-equivalent for same-user code.
Only attach to a process **you control** for development/diagnostics. **Never enable the inspector on, or
attach to, a production process.**

## 1. Enable the target (one-time, at launch)

The target only accepts connections if it was *started* with the inspector hook — you cannot enable an
already-running process. `inspect init` prints the env vars to set in the shell that launches it:

```
csharprepl inspect init # prints DOTNET_STARTUP_HOOKS=... and ASPNETCORE_HOSTINGSTARTUPASSEMBLIES=...
# auto-detects your shell; override with --shell bash|pwsh|cmd|fish
```

Set those env vars in the launching shell only (not system- or user-wide), then start the app normally
(e.g. `dotnet run`). It's now attachable for the life of that process.

## 2. Attach and evaluate

```
csharprepl inspect list # list inspector-enabled processes + their PIDs
csharprepl inspect <pid> -e 'System.Environment.ProcessId' # -> the target's PID; confirms code runs in the target
csharprepl inspect <pid> --eval-file probe.csx # multi-line, same as local
echo 'SomeApp.Program.SomeStatic' | csharprepl inspect <pid> # piped stdin works too
```

- **Reach the target's state** by fully-qualified name (`MyApp.Program.SomeStatic`), or, when its DI provider
was captured, via `services.GetRequiredService<T>()` / `Get<T>()` (the connect banner reports whether the
DI provider was captured).
- **State persists across calls** (unlike local `csharp-eval`, where each run is a fresh process): the target
holds the script-state chain, so a `var` declared in one `inspect <pid> -e` invocation is usable in the
next. This lets you build up state with one-shot calls.

## 3. Live method replacement

While attached you can detour a live method to a REPL-defined delegate, changing the running app's behavior
immediately:

- `#replace <Type.Method> with <delegate>` — swap the implementation.
- `#wrap <Type.Method> with <delegate>` — keep the original, callable via an `orig` first parameter.
- `#patches` — list active patches; `#revert <id>` / `#revert all` — undo them.

Instance methods take the instance as the first delegate parameter; a static method omits it. Define the
helper in one call, then `#replace` in the next — they share state across calls:

```
csharprepl inspect <pid> -e 'decimal Half(MyApp.OrderService svc, int qty, decimal unit) => qty * unit * 0.5m;'
csharprepl inspect <pid> -e '#replace MyApp.OrderService.CalculatePrice with Half'
csharprepl inspect <pid> -e '#patches' # list active patches
csharprepl inspect <pid> -e '#revert all' # undo them
```

- A command (`#replace`/`#wrap`/`#patches`/`#revert`) must be the **whole** submission — don't combine a
definition and a command in one `-e` or one piped block, since collected stdin is sent as a single C#
submission (so `#replace` would be compiled as invalid C#). To do it in one pipe, use `--streamPipedInput`,
which evaluates line by line.
- Patches **persist in the target until reverted** (or it exits) — they outlive your detach, so revert when
done.
- Not supported: generic methods, pointer params, and `#wrap` with by-ref parameters.

## Gotchas

- **Can't attach if not enabled.** `inspect <pid>` fails unless the target was launched with the env vars
from `inspect init` (step 1). Use `inspect list` to see what's actually attachable.
- **Self-contained single-file targets are rejected** — their assemblies are bundled in memory with no
on-disk path, so the engine can't compile against them. Inspect a framework-dependent build instead. (A
*framework-dependent* single-file app connects but can only reach its own types via reflection.)
- **Detach leaves the target running.** `inspect` exits cleanly; the process keeps going and you can
reconnect — but any patches you applied stay in effect until reverted.
- Everything else (quoting, NuGet refs, errors→stderr, exit codes) works as in **csharp-eval**.
4 changes: 3 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ The test runner is **Microsoft.Testing.Platform** with the **xUnit v3** runner (

- Heavy Roslyn/integration tests share `[Collection(nameof(RoslynServices))]` and run **serially** on purpose: the loader's `AssemblyLoadContext.Resolving` hooks (attached to the process-global Default ALC by `AssemblyLoadContextHook` and never detached) are process-global, not per-`RoslynServices`. The full suite is ~2 minutes. (`MSBuildLocator.RegisterDefaults()` is also process-global, but a `[ModuleInitializer]` in `TestAssemblyInitializer` runs it once at assembly load, so it's no longer the reason for the collection — and tests that only needed MSBuildLocator, like `NugetPackageInstallerTests`, can now run in isolation.)
- Some tests spawn `dotnet build` / MSBuild subprocesses (solution/project references) and a few touch the network (NuGet install). These are the slow ones and can occasionally be flaky.
- The inspect-feature integration tests (`InspectorRoundTripTests`, `InspectorCancellationTests`, `RemoteEditorServicesTests`, `InspectorServerProtocolTests`, `RemoteReadEvalPrintLoopTests`) **launch a real hooked child process** — the interactive PrettyPrompt loop itself cannot be driven without a TTY, so `RemoteReadEvalPrintLoopTests` stubs `IPrompt` (like `ReadEvalPrintLoopTests`) and everything below it is real. Two more inspect suites run in-process without a child: `InspectorEngineTests` hosts the real engine + Roslyn inside the test process, and `InspectorTransportTests` exercises the real OS pipe/socket transport (note: the Windows pipe uses zero-byte buffers, so a write rendezvouses with the peer's read — keep the read pending while writing).
- The inspect-feature integration tests (`InspectorRoundTripTests`, `InspectorCancellationTests`, `RemoteEditorServicesTests`, `InspectorServerProtocolTests`, `RemoteReadEvalPrintLoopTests`, `RemotePipedInputEvaluatorTests`) **launch a real hooked child process** — the interactive PrettyPrompt loop itself cannot be driven without a TTY, so `RemoteReadEvalPrintLoopTests` stubs `IPrompt` (like `ReadEvalPrintLoopTests`) and everything below it is real. The **non-interactive** inspect path (`inspect <pid> --eval`/`--eval-file`/piped stdin) needs no TTY, so `RemotePipedInputEvaluatorTests` drives the real `RemotePipedInputEvaluator` against a real hooked child directly. Two more inspect suites run in-process without a child: `InspectorEngineTests` hosts the real engine + Roslyn inside the test process, and `InspectorTransportTests` exercises the real OS pipe/socket transport (note: the Windows pipe uses zero-byte buffers, so a write rendezvouses with the peer's read — keep the read pending while writing).

### Benchmarks

Expand Down Expand Up @@ -90,6 +90,8 @@ This is the crux of the design — the target may already load its own Roslyn, s

When attached, csharprepl is a thin **controller**: it compiles nothing for evaluation, sends code strings, and renders the returned `RemoteValue` through the *same* theme/formatting pipeline as local output (`RemoteValueRenderer`). The **scripting world lives in the target** (the engine), but the **workspace world (completion/highlighting) stays in the controller** against a second, remote-configured `RoslynServices` seeded with the target's assembly paths + `InspectorGlobals` — so editor features need no per-keystroke round-trip. The controller advances that remote workspace only when an `EvalResponse` reports `Committed == true`.

Inspect mode also runs **non-interactively** (so agents/scripts can use it without a TTY), mirroring the local REPL's `PipedInputEvaluator`: `inspect <pid> --eval`/`--eval-file` or piped stdin route to `RemotePipedInputEvaluator` instead of the interactive `RemoteReadEvalPrintLoop`. It evaluates against the same `RemoteSession`, auto-prints the final value as plain, uncolored, unwrapped text on stdout (errors to stderr, nonzero exit), and honors the same `#replace`/`#wrap`/`#patches`/`#revert` commands (the command-result wording is shared via `InspectorCommandResultPrinter`). It skips the editor-workspace seeding (completion/highlighting are interactive-only) and the connection chatter. Because the engine's state chain lives in the target, separate one-shot `--eval` invocations **accumulate state** across reconnects.

### Wire protocol

A single duplex connection (named pipe on Windows, Unix domain socket elsewhere, **current-user only**). Every frame is a 4-byte little-endian length prefix + UTF-8 JSON body; messages are a `System.Text.Json` **polymorphic** `WireMessage` hierarchy keyed on a `$kind` discriminator (`MessageChannel`). Security model mirrors the .NET diagnostic port — OS access control, no secret. An inspector-enabled process is RCE-equivalent for same-user code and must never run in production.
Expand Down
2 changes: 2 additions & 0 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,8 @@ This mirrors the local REPL's "two Roslyn worlds kept in sync" idea (see *Roslyn
- The **scripting world** (executing code, holding script state) lives in the **target**, inside the engine. This is what evaluation and result-rendering already use.
- The **workspace world** (syntax highlighting, autocompletion, symbol lookup) stays in the **controller**, because those are metadata operations that need the target's *types* and prior submission text, not its live object values — so they run locally without a per-keystroke round-trip over the transport. On connect the controller asks the inspector for the target's loaded-assembly paths and constructs a second, remote-configured `RoslynServices` seeded with them plus the inspector globals (so `services`/`Get<T>()` resolve); the ordinary prompt callbacks then drive that target-aware workspace. It advances only when the engine reports a submission committed — the cross-process analogue of how the local REPL keeps its scripting and workspace APIs consistent.

**Non-interactive inspect.** The interactive prompt above (`RemoteReadEvalPrintLoop`) needs a TTY, so for agents and scripts inspect mode also has a headless path that parallels the local REPL's `PipedInputEvaluator`. `inspect <pid> --eval`/`--eval-file` or piped stdin route to `RemotePipedInputEvaluator`, which drives the same `RemoteSession`, auto-prints the final value as plain/uncolored/unwrapped text on stdout (errors to stderr, nonzero exit), and honors the same `#replace`/`#wrap`/`#patches`/`#revert` commands (wording shared with the interactive loop via `InspectorCommandResultPrinter`). It skips the editor-workspace seeding above — completion/highlighting are interactive-only, so the reference round-trip would be wasted — and emits no connection chatter. Since the persisted script state lives in the target, separate one-shot `--eval` invocations accumulate state across reconnects.

### Wire protocol and message flow

Controller and inspector talk over a single duplex connection — a named pipe on Windows, a Unix domain socket elsewhere, scoped to the current user. Every message is a `WireMessage` framed by `MessageChannel` as a **4-byte little-endian length prefix followed by a UTF-8 JSON body**; the JSON is a `System.Text.Json` polymorphic hierarchy keyed on a `$kind` discriminator, so a single read returns the right concrete message. Frame lengths are bounded and malformed frames surface as a catchable exception rather than crashing either process. All of these types live in the shared `CSharpRepl.InjectedHook.Contracts` assembly, so they're type-identical on both sides.
Expand Down
24 changes: 24 additions & 0 deletions CSharpRepl.Services/Remote/RemoteValueRenderer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
// file, You can obtain one at https://mozilla.org/MPL/2.0/.

using System;
using System.IO;
using CSharpRepl.InjectedHook.Contracts;
using CSharpRepl.Services.Roslyn.Formatting;
using CSharpRepl.Services.SyntaxHighlighting;
Expand Down Expand Up @@ -39,6 +40,29 @@ internal sealed class RemoteValueRenderer
_ => Styled(value.DisplayText, value.Style),
};

/// <summary>
/// Renders a <see cref="RemoteValue"/> to plain, uncolored, unwrapped text for non-interactive output
/// (<c>inspect &lt;pid&gt; --eval</c> / piped input): the same layout as <see cref="Render"/>, with styling
/// and width-wrapping stripped so the value is safe to capture or pipe.
/// </summary>
public string RenderToPlainText(RemoteValue value, Level level) => ToPlainText(Render(value, level));

/// <summary>Renders a Spectre <see cref="IRenderable"/> with colors off and wrapping effectively off (very
/// wide profile), so the output carries no ANSI sequences or word-wrap.</summary>
private static string ToPlainText(IRenderable renderable)
{
var writer = new StringWriter();
var plainConsole = AnsiConsole.Create(new AnsiConsoleSettings
{
Ansi = AnsiSupport.No,
ColorSystem = ColorSystemSupport.NoColors,
Out = new AnsiConsoleOutput(writer),
});
plainConsole.Profile.Width = 100_000;
plainConsole.Write(renderable);
return writer.ToString().TrimEnd('\r', '\n');
}

/// <summary>Renders a remote exception as a red-bordered panel, mirroring the local REPL's error rendering.</summary>
public (IRenderable Renderable, string PlainText) RenderException(RemoteException exception, Level level)
{
Expand Down
9 changes: 7 additions & 2 deletions CSharpRepl.Services/Roslyn/RoslynServices.cs
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,10 @@ public async Task<EvaluationResult> EvaluateAsync(string input, string[]? args =
public IRenderable RenderRemoteValue(RemoteValue value, Level level) =>
(remoteValueRenderer ??= new RemoteValueRenderer(highlighter)).Render(value, level);

/// <summary>Plain-text analogue of <see cref="RenderRemoteValue"/> for non-interactive inspect output.</summary>
public string RenderRemoteValueToPlainText(RemoteValue value, Level level) =>
(remoteValueRenderer ??= new RemoteValueRenderer(highlighter)).RenderToPlainText(value, level);

/// <summary>Renders a remote exception as a red-bordered panel plus the plain text to use if error output is redirected.</summary>
public (IRenderable Renderable, string PlainText) RenderRemoteException(RemoteException exception, Level level) =>
(remoteValueRenderer ??= new RemoteValueRenderer(highlighter)).RenderException(exception, level);
Expand Down Expand Up @@ -332,8 +336,9 @@ public async Task<IReadOnlyCollection<HighlightedSpan>> SyntaxHighlightAsync(str

public async Task<bool> IsTextCompleteStatementAsync(string text)
{
if (!Initialization.IsCompleted)
return true;
// Unlike the per-keystroke editor fast-paths (CompleteAsync/SyntaxHighlightAsync return [] before init),
// this is reached only when submitting (Enter), so we can await initialization without a fast-path.
await Initialization.ConfigureAwait(false);

var document = workspaceManager.CurrentDocument.WithText(SourceText.From(text));
var root = await document.GetSyntaxRootAsync().ConfigureAwait(false);
Expand Down
Loading
Loading