Compare commits

...

10 Commits

Author SHA1 Message Date
4105f02468 docs: add language navigation anchors to README
Some checks failed
CI / Lint (push) Failing after 1m42s
CI / Build & Test (ubuntu-latest) (push) Successful in 42s
CI / Build & Test (macos-latest) (push) Has been cancelled
CI / Build & Test (windows-latest) (push) Has been cancelled
CI / Build Roslyn Helper (push) Has been cancelled
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 15:37:09 -03:00
6ace5b348e docs: add Claude Code integration section with PATH setup and CLAUDE.md template 2026-04-28 14:34:01 -03:00
10f8ab530c docs: add step-by-step Go install instructions with link 2026-04-28 14:30:25 -03:00
542315d1c8 docs: add PT-BR/EN readme with benchmark results 2026-04-28 13:52:59 -03:00
22f6e8fde9 chore: add golangci-lint, GitHub Actions CI, and pre-commit hooks
Adds .golangci.yml (errcheck, govet, staticcheck, revive, gocyclo,
gocritic, nilerr, errorlint, etc.). Fixes all lint issues found:
- RpcError/wrapRpc renamed to RPCError/wrapRPC (revive var-naming)
- Shadow vars resolved in process.go, outline.go, project.go
- nilerr suppressed with justifying comments in git/collect.go and
  auto/detector.go (WalkDir / intentional empty-returns)
- if-else chain rewritten as switch in format.go (gocritic)
- gofmt applied to detector.go and client.go

Adds .github/workflows/ci.yml: lint (ubuntu), build+vet+test matrix
(ubuntu/windows/macos), and Roslyn helper build (windows). Validated
with actionlint (0 issues).

Adds .githooks/pre-commit + pre-commit.ps1 for local pre-commit checks.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 00:30:54 -03:00
15dc1b6b2f feat(csharp): implement 'outline' command for single-file structural summary
Adds OutlineHandler.cs to the Roslyn helper using CSharpSyntaxTree.ParseText
and manual syntax tree traversal (no solution load required). Extracts
namespaces, types, method/property/field/event signatures and line numbers
without method bodies. Handles file-scoped namespaces, generics, records,
nested types, partial classes, top-level statements, and [Obsolete] markers.

Adds Microsoft.CodeAnalysis.CSharp 4.13.0 as the sole new NuGet dependency
(pure parser, no MSBuild coupling). Go side adds Outline() client method,
OutlineResult/Type/Member types, outline.go command, and WriteOutline formatter.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 19:41:47 -03:00
59cb2b5ddb feat(csharp): implement 'project' command via Roslyn helper
Replaces placeholder with full csharp@0.1.0 plugin. Adds helper/
package (locate, process, client, protocol) for JSON-RPC over stdio
to ctx-roslyn-helper. project.go finds .sln (fallback: single .csproj),
loads it, retrieves projectSummary, formats dense markdown with project
details, reference graph, and multi-targeting section.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 19:31:00 -03:00
e67234345b feat(roslyn-helper): C# subprocess helper with JSON-RPC over stdin/stdout
Implements Prompt 3. Newline-delimited JSON-RPC dispatcher (ping,
loadSolution, projectSummary) with pure XML/.sln parsing — no Roslyn
or MSBuild NuGet packages (irreconcilable .NET 10 SDK API mismatches).
XDocument parses .csproj for frameworks/packages/refs; regex parses
.sln project entries; filesystem walk counts .cs/.fs/.vb documents.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 18:12:21 -03:00
8e022bf5a5 feat(auto): implement stack detection with csharp/react/go/python heuristics
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 15:56:36 -03:00
6852be77ff feat(git): implement compact git status summary
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 13:50:32 -03:00
39 changed files with 3352 additions and 66 deletions

36
.githooks/pre-commit Normal file
View File

@ -0,0 +1,36 @@
#!/bin/sh
# ctx pre-commit hook — shell entry point (works in Git Bash on Windows)
# Delegates to pre-commit.ps1 on Windows, runs natively on Linux/macOS.
#
# To activate:
# git config core.hooksPath .githooks
set -e
if command -v powershell.exe >/dev/null 2>&1; then
# Windows: delegate to PowerShell script
exec powershell.exe -ExecutionPolicy Bypass -File .githooks/pre-commit.ps1
fi
# Linux / macOS: run directly
echo "Running gofmt..."
unformatted=$(gofmt -l .)
if [ -n "$unformatted" ]; then
echo "Files not formatted with gofmt:"
echo "$unformatted"
echo
echo "Run: gofmt -w ."
exit 1
fi
echo "Running go vet..."
go vet ./...
if command -v golangci-lint >/dev/null 2>&1; then
echo "Running golangci-lint..."
golangci-lint run ./...
else
echo "warning: golangci-lint not installed, skipping"
fi
echo "pre-commit checks passed"

25
.githooks/pre-commit.ps1 Normal file
View File

@ -0,0 +1,25 @@
# ctx pre-commit hook (PowerShell)
# To activate: git config core.hooksPath .githooks
$ErrorActionPreference = "Stop"
Write-Host "Running gofmt..."
$unformatted = gofmt -l .
if ($unformatted) {
Write-Host "Files not formatted with gofmt:"
Write-Host $unformatted
Write-Host ""
Write-Host "Run: gofmt -w ."
exit 1
}
Write-Host "Running go vet..."
go vet ./...
if (Get-Command golangci-lint -ErrorAction SilentlyContinue) {
Write-Host "Running golangci-lint..."
golangci-lint run ./...
} else {
Write-Host "warning: golangci-lint not installed, skipping"
}
Write-Host "pre-commit checks passed"

55
.github/workflows/ci.yml vendored Normal file
View File

@ -0,0 +1,55 @@
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
lint:
name: Lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: "1.26"
cache: true
- name: golangci-lint
uses: golangci/golangci-lint-action@v6
with:
version: latest
build:
name: Build & Test
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: "1.26"
cache: true
- name: Build
run: go build -v ./...
- name: Vet
run: go vet ./...
- name: Test
run: go test -v -race -count=1 ./...
build-helper:
name: Build Roslyn Helper
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: "10.0.x"
- name: Build helper
working-directory: tools/roslyn-helper
run: dotnet build src/RoslynHelper -c Release

5
.gitignore vendored
View File

@ -27,6 +27,11 @@ coverage.html
# Claude Code local settings # Claude Code local settings
.claude/ .claude/
# Roslyn helper artifacts
tools/roslyn-helper/**/bin/
tools/roslyn-helper/**/obj/
tools/roslyn-helper/publish/
# OS artifacts # OS artifacts
.DS_Store .DS_Store
Thumbs.db Thumbs.db

69
.golangci.yml Normal file
View File

@ -0,0 +1,69 @@
run:
timeout: 3m
tests: true
go: "1.26"
output:
formats:
- format: colored-line-number
print-issued-lines: true
print-linter-name: true
sort-results: true
linters:
disable-all: true
enable:
- errcheck # unhandled errors
- govet # official compiler checks
- ineffassign # useless assignments
- staticcheck # bugs and simplifications
- unused # dead code
- gofmt # formatting
- goimports # import organization
- misspell # typos in strings/comments
- revive # golint successor
- gocyclo # high cyclomatic complexity
- gocritic # additional checks
- bodyclose # http.Response.Body closed
- nilerr # returning nil when err intended
- errorlint # non-wrappable errors
linters-settings:
gocyclo:
min-complexity: 15
govet:
enable-all: true
disable:
- fieldalignment # unnecessary noise
revive:
rules:
- name: var-naming
- name: exported
arguments: ["disableStutteringCheck"] # allows ctx.Context
- name: error-return
- name: error-naming
- name: unexported-return
- name: indent-error-flow
- name: unreachable-code
issues:
exclude-dirs:
- tools/roslyn-helper # C# code, ignore
- vendor
- bin
exclude-rules:
# Allow errcheck in cobra command handlers — cobra's RunE owns error printing
- path: cmd/
linters: [errcheck]
# Allow higher complexity in plugin format functions (inherently procedural)
- path: internal/plugins/.*/format\.go
linters: [gocyclo]
text: "cyclomatic complexity"
# Allow higher complexity in plugin collect/detect logic
- path: internal/plugins/.*/detect
linters: [gocyclo]
text: "cyclomatic complexity"
max-issues-per-linter: 0
max-same-issues: 0
new: false

244
README.md
View File

@ -1,3 +1,145 @@
# ctx
Ferramenta CLI para compactar contexto de código antes de enviar ao Claude — economize tokens, expanda o que cabe na janela.
[→ Leia em Português](#pt-br) · [→ Read in English](#en)
---
<a id="pt-br"></a>
# ctx — anti-tokens CLI para Claude Code
> Analise seu projeto localmente. Envie ao Claude resumos densos, não arquivos brutos.
**Status:** alpha — em desenvolvimento ativo. Interfaces podem mudar.
## Por que usar
Cada token que o Claude lê tem custo e consome janela de contexto. Ao trabalhar em projetos grandes, o Claude frequentemente gasta milhares de tokens apenas lendo arquivos para construir um modelo mental antes de fazer qualquer trabalho real.
`ctx` roda localmente, analisa seu projeto com ferramentas especializadas por linguagem (Roslyn para C#, tree-sitter para TypeScript), e emite resumos compactos em markdown que dão ao Claude tudo que ele precisa em uma fração dos tokens.
### Benchmark
Teste real: análise de "o que preciso mudar para adicionar uma feature" em projeto em produção.
Ambas as sessões iniciadas com `/clear` — condições equivalentes.
| Teste | Consumo de janela de contexto |
|-------|-------------------------------|
| Sem ctx | +7% |
| Com ctx | +3% |
**Economia: 57%.** Em sessões longas de desenvolvimento, onde o Claude faz a mesma exploração várias vezes ao longo da conversa, essa economia acumula.
## Instalação
**1. Instale o Go**
Baixe em https://go.dev/dl/ e siga o instalador. Requer Go 1.22+.
**2. Instale o ctx**
```sh
go install github.com/ricarneiro/ctx/cmd/ctx@latest
```
**3. Verifique**
```sh
ctx --version
```
## Uso
```sh
# Contexto git: commits recentes, status, info da branch
ctx git
# Detectar stack e emitir visão geral do projeto
ctx auto project
# Estrutura de projeto C# (requer .NET SDK)
ctx csharp project
# Outline de arquivo C#: tipos, métodos, assinaturas
ctx csharp outline src/MyService.cs
# Listar erros de compilação
ctx csharp errors
```
Toda saída é markdown UTF-8 no stdout. Redirecione onde precisar:
```sh
ctx csharp project | clip # Windows
ctx csharp project | pbcopy # macOS
```
## Integrando com Claude Code
Para que o Claude Code use o `ctx` automaticamente, adicione o `ctx` ao PATH do Windows e inclua as instruções abaixo no `CLAUDE.md` do seu projeto.
### 1. Adicionar ctx ao PATH no Windows
1. Copie o `ctx.exe` para uma pasta fixa (ex: `C:\tools\ctx\`)
2. Abra **Configurações do Sistema → Variáveis de Ambiente**
3. Em "Variáveis do sistema", selecione `Path` → **Editar**
4. Clique em **Novo** e adicione `C:\tools\ctx\`
5. Confirme com OK e reinicie o terminal
### 2. Adicionar ao CLAUDE.md do projeto
Crie ou edite o arquivo `CLAUDE.md` na raiz do seu projeto e inclua:
````markdown
# Project instructions
## `ctx` — compact code analysis tool (use to save tokens)
This project has `ctx` in PATH. It produces compact markdown summaries of code structure.
### When to use `ctx` (saves tokens)
- **Understanding the whole project:** run `ctx csharp project` instead of exploring files one by one.
- **Understanding a large file (100+ lines):** run `ctx csharp outline <file>` before reading the full file. Only read the full file if you need method body details.
- **Checking build errors:** run `ctx csharp errors` instead of `dotnet build` (filtered output, 90% smaller).
- **Understanding git state:** run `ctx git` instead of running multiple git commands.
### When NOT to use `ctx`
- If you already know which specific small file to read, just read it directly.
- If you need to see method body logic, read the file — `ctx outline` only shows signatures.
- For simple `Search()` by pattern across files, search is already efficient.
### Commands
```
ctx csharp project # solution overview: projects, refs, packages, structure
ctx csharp outline <file.cs> # class/method signatures without bodies (~85% smaller)
ctx csharp errors # dotnet build filtered to errors + top warnings (~90% smaller)
ctx git # branch, status, recent commits, diff summary
```
### Rule of thumb
Ask yourself: "Am I about to read multiple files to understand structure?" If yes → `ctx` first. If you just need one specific file → read it directly.
````
## Contribuindo
Contribuições são bem-vindas. Clone o repositório, faça suas alterações e abra um Pull Request. Para mudanças grandes, abra uma issue primeiro para alinhar o escopo.
```sh
git clone https://github.com/ricarneiro/CTX.git
cd CTX
go build -o ctx.exe ./cmd/ctx
```
## Licença
MIT — veja [LICENSE](LICENSE).
---
<a id="en"></a>
# ctx — anti-tokens CLI for Claude Code # ctx — anti-tokens CLI for Claude Code
> Analyze your codebase locally. Feed Claude dense summaries, not raw files. > Analyze your codebase locally. Feed Claude dense summaries, not raw files.
@ -6,22 +148,36 @@
## Why ## Why
Every token Claude reads costs money and burns context window. When working on Every token Claude reads costs money and burns context window. When working on a large codebase, Claude often spends thousands of tokens just reading files to build a mental model before doing any actual work.
a large C# or React codebase, Claude often spends thousands of tokens just
reading files to build a mental model before doing any actual work.
`ctx` runs locally, analyzes your project with language-aware tools (Roslyn for `ctx` runs locally, analyzes your project with language-aware tools (Roslyn for C#, tree-sitter for TypeScript), and emits compact markdown summaries that give Claude everything it needs in a fraction of the tokens.
C#, tree-sitter for TypeScript), and emits compact markdown summaries that give
Claude everything it needs in a fraction of the tokens. ### Benchmark
Real test: analyzing "what needs to change to add a feature" on a production project.
Both sessions started with `/clear` — equivalent conditions.
| Test | Context window usage |
|------|----------------------|
| Without ctx | +7% |
| With ctx | +3% |
**Savings: 57%.** In long development sessions, where Claude performs the same exploration repeatedly throughout a conversation, the savings compound.
## Installation ## Installation
**1. Install Go**
Download from https://go.dev/dl/ and run the installer. Requires Go 1.22+.
**2. Install ctx**
```sh ```sh
go install github.com/ricarneiro/ctx/cmd/ctx@latest go install github.com/ricarneiro/ctx/cmd/ctx@latest
``` ```
Requires Go 1.22+. Binaries for common platforms will be published after the **3. Verify**
MVP is validated. ```sh
ctx --version
```
## Usage ## Usage
@ -49,41 +205,65 @@ ctx csharp project | pbcopy # macOS
ctx csharp project | clip # Windows ctx csharp project | clip # Windows
``` ```
Or reference it in a `CLAUDE.md`: ## Integrating with Claude Code
```markdown To have Claude Code use `ctx` automatically, add `ctx` to your Windows PATH and include the instructions below in your project's `CLAUDE.md`.
Run `ctx csharp project` to get the project overview before making changes.
```
## Architecture ### 1. Add ctx to PATH on Windows
`ctx` uses a plugin system. Each stack (`git`, `csharp`, `react`, `auto`) is a 1. Copy `ctx.exe` to a fixed folder (e.g. `C:\tools\ctx\`)
plugin that implements the `core.Plugin` interface and registers itself via 2. Open **System Settings → Environment Variables**
`init()`. 3. Under "System variables", select `Path` → **Edit**
4. Click **New** and add `C:\tools\ctx\`
5. Confirm with OK and restart your terminal
### 2. Add to your project's CLAUDE.md
Create or edit `CLAUDE.md` at the root of your project and include:
````markdown
# Project instructions
## `ctx` — compact code analysis tool (use to save tokens)
This project has `ctx` in PATH. It produces compact markdown summaries of code structure.
### When to use `ctx` (saves tokens)
- **Understanding the whole project:** run `ctx csharp project` instead of exploring files one by one.
- **Understanding a large file (100+ lines):** run `ctx csharp outline <file>` before reading the full file. Only read the full file if you need method body details.
- **Checking build errors:** run `ctx csharp errors` instead of `dotnet build` (filtered output, 90% smaller).
- **Understanding git state:** run `ctx git` instead of running multiple git commands.
### When NOT to use `ctx`
- If you already know which specific small file to read, just read it directly.
- If you need to see method body logic, read the file — `ctx outline` only shows signatures.
- For simple `Search()` by pattern across files, search is already efficient.
### Commands
``` ```
core.Plugin interface ctx csharp project # solution overview: projects, refs, packages, structure
Name() string ctx csharp outline <file.cs> # class/method signatures without bodies (~85% smaller)
Version() string ctx csharp errors # dotnet build filtered to errors + top warnings (~90% smaller)
ShortDescription() string ctx git # branch, status, recent commits, diff summary
Command(ctx *core.Context) *cobra.Command
``` ```
In the MVP, plugins are compiled into the binary. Future plan: migrate to ### Rule of thumb
subprocess dispatch (binaries named `ctx-csharp`, `ctx-react` in PATH), same
pattern as `kubectl` plugins.
C# analysis uses a separate helper process (`tools/roslyn-helper/`) written in Ask yourself: "Am I about to read multiple files to understand structure?" If yes → `ctx` first. If you just need one specific file → read it directly.
C# with Roslyn. The helper communicates with `ctx` via JSON-RPC over ````
stdin/stdout. This lets us use the best tool for the job without pulling a .NET
runtime into the Go binary.
See `docs/DECISIONS.md` for the full rationale behind each architectural choice.
## Contributing ## Contributing
Contributions welcome. See `CONTRIBUTING.md` (coming soon) for guidelines. Contributions are welcome. Clone the repository, make your changes, and open a Pull Request. For large changes, open an issue first to align on scope.
Open an issue first if you plan a large change.
```sh
git clone https://github.com/ricarneiro/CTX.git
cd CTX
go build -o ctx.exe ./cmd/ctx
```
## License ## License

View File

@ -22,7 +22,8 @@ Each subcommand targets a specific stack:
ctx react React / TypeScript analysis ctx react React / TypeScript analysis
Output is always UTF-8 markdown on stdout, suitable for piping into Claude.`, Output is always UTF-8 markdown on stdout, suitable for piping into Claude.`,
SilenceUsage: true, SilenceUsage: true,
SilenceErrors: true, // plugins print their own errors to stderr
} }
// Execute runs the root command. Called by cmd/ctx/main.go. // Execute runs the root command. Called by cmd/ctx/main.go.

View File

@ -1,31 +1,178 @@
// Package auto implements the ctx auto plugin. // Package auto implements the ctx auto plugin — stack detection and routing.
// Full implementation: prompt 2.
package auto package auto
import ( import (
"fmt" "fmt"
"strings"
"github.com/ricarneiro/ctx/internal/core" "github.com/ricarneiro/ctx/internal/core"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
func init() { func init() {
core.Register(&autoPlugin{}) core.Register(&plugin{})
} }
type autoPlugin struct{} type plugin struct{}
func (a *autoPlugin) Name() string { return "auto" } func (p *plugin) Name() string { return "auto" }
func (a *autoPlugin) Version() string { return "0.0.1" } func (p *plugin) Version() string { return "0.1.0" }
func (a *autoPlugin) ShortDescription() string { return "Auto-detect project stack and emit context" } func (p *plugin) ShortDescription() string { return "Detect stack and route to appropriate plugin" }
func (a *autoPlugin) Command(ctx *core.Context) *cobra.Command { func (p *plugin) Command(ctx *core.Context) *cobra.Command {
return &cobra.Command{ cmd := &cobra.Command{
Use: "auto", Use: "auto",
Short: a.ShortDescription(), Short: p.ShortDescription(),
Long: "Auto-detect the project stack and route to the appropriate ctx plugin.",
}
cmd.AddCommand(newDetectCmd(ctx))
cmd.AddCommand(newProjectCmd(ctx))
return cmd
}
// --- detect subcommand ---
func newDetectCmd(ctx *core.Context) *cobra.Command {
return &cobra.Command{
Use: "detect",
Short: "List detected stacks in the current directory",
Long: "Scan the current directory and list detected technology stacks without running any plugin.",
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
fmt.Fprintln(ctx.Stderr, "Not implemented yet — coming in prompt 2") return runDetect(ctx)
return nil
}, },
} }
} }
func runDetect(ctx *core.Context) error {
stacks, err := Detect(ctx.WorkDir)
if err != nil {
fmt.Fprintln(ctx.Stderr, err.Error())
return fmt.Errorf("exit 1")
}
formatDetect(ctx, stacks)
return nil
}
func formatDetect(ctx *core.Context, stacks []Stack) {
w := ctx.Stdout
fmt.Fprintln(w, "# Stack detection")
fmt.Fprintln(w)
fmt.Fprintf(w, "**directory:** %s\n\n", ctx.WorkDir)
if len(stacks) == 0 {
fmt.Fprintln(w, "No known stack detected.")
fmt.Fprintln(w)
plugins := core.All()
names := make([]string, len(plugins))
for i, p := range plugins {
names[i] = p.Name()
}
fmt.Fprintf(w, "Available plugins: %s\n", strings.Join(names, ", "))
fmt.Fprintln(w, "Run `ctx <plugin> --help` to see what each plugin offers.")
return
}
fmt.Fprintln(w, "## Detected stacks")
for _, s := range stacks {
fmt.Fprintf(w, "- **%s** (confidence: %s)\n", s.Name, s.Confidence)
for _, e := range s.Evidence {
fmt.Fprintf(w, " - %s\n", e)
}
}
fmt.Fprintln(w)
fmt.Fprintln(w, "## Suggested commands")
for _, s := range stacks {
fmt.Fprintf(w, "- `ctx %s project`\n", s.Name)
}
}
// --- project subcommand ---
func newProjectCmd(ctx *core.Context) *cobra.Command {
return &cobra.Command{
Use: "project",
Short: "Detect stack and emit project context summary",
Long: "Auto-detect the project stack and run the `project` command of each matching plugin.",
RunE: func(cmd *cobra.Command, args []string) error {
return runProject(ctx)
},
}
}
func runProject(ctx *core.Context) error {
stacks, err := Detect(ctx.WorkDir)
if err != nil {
fmt.Fprintln(ctx.Stderr, err.Error())
return fmt.Errorf("exit 1")
}
if len(stacks) == 0 {
formatDetect(ctx, stacks)
return nil
}
first := true
for _, stack := range stacks {
if !first {
fmt.Fprintln(ctx.Stdout, "---")
fmt.Fprintln(ctx.Stdout)
}
first = false
p := core.Get(stack.Name)
if p == nil {
fmt.Fprintf(ctx.Stdout,
"# %s\n\nNo plugin registered for stack `%s`.\n\n",
stack.Name, stack.Name)
continue
}
pluginCmd := p.Command(ctx)
// If the root command itself is a placeholder, the whole plugin is unimplemented.
if pluginCmd.Annotations["placeholder"] == "true" {
writePlaceholder(ctx, stack.Name)
continue
}
sub := findSubcommand(pluginCmd, "project")
if sub == nil {
fmt.Fprintf(ctx.Stdout,
"# %s\n\nThe `%s` plugin does not have a `project` command.\n\n",
stack.Name, stack.Name)
continue
}
if sub.Annotations["placeholder"] == "true" {
writePlaceholder(ctx, stack.Name)
continue
}
// Execute the project subcommand directly (no cobra routing overhead).
if sub.RunE != nil {
if err := sub.RunE(sub, []string{}); err != nil {
fmt.Fprintf(ctx.Stderr, "error running %s project: %v\n", stack.Name, err)
}
} else if sub.Run != nil {
sub.Run(sub, []string{})
}
}
return nil
}
// findSubcommand returns the named subcommand of cmd, or nil if not found.
func findSubcommand(cmd *cobra.Command, name string) *cobra.Command {
for _, sub := range cmd.Commands() {
if sub.Name() == name {
return sub
}
}
return nil
}
func writePlaceholder(ctx *core.Context, name string) {
fmt.Fprintf(ctx.Stdout,
"# %s (placeholder)\n\nThe `%s` plugin detected this stack but its `project` command is not yet implemented.\nThis will be available in a future version of ctx.\n\n",
name, name)
}

View File

@ -0,0 +1,258 @@
package auto
import (
"encoding/json"
"fmt"
"io/fs"
"os"
"path/filepath"
"sort"
"strings"
)
// Stack represents a detected technology stack in a project directory.
type Stack struct {
Name string // "csharp", "react", "go", "python"
Confidence string // "high", "medium", "low"
Evidence []string // human-readable items explaining the detection
}
// Detect scans rootDir up to 3 levels deep and returns detected stacks,
// ordered by confidence (high first). Build artifacts and dependency
// directories (node_modules, bin, obj, etc.) are skipped.
func Detect(rootDir string) ([]Stack, error) {
f, err := scanDir(rootDir, 3)
if err != nil {
return nil, err
}
return buildStacks(f), nil
}
// skipDirs lists directory names that are never descended into during scanning.
var skipDirs = map[string]bool{
"node_modules": true,
"bin": true,
"obj": true,
"dist": true,
"build": true,
"out": true,
".git": true,
".vs": true,
".vscode": true,
".idea": true,
"target": true,
"vendor": true,
}
// findings holds raw evidence gathered during the directory walk.
type findings struct {
slnFiles []string // forward-slash relative paths to .sln files
csprojFiles []string // forward-slash relative paths to .csproj/.fsproj/.vbproj files
hasGlobalJSON bool
packageJSONs []pkgJSON
hasGoModRoot bool // go.mod is a direct child of rootDir (depth 1)
hasPyProject bool
hasReqTxt bool
hasSetupPy bool
hasTSXorJSX bool
}
type pkgJSON struct {
relPath string
hasReact bool
hasTypeScript bool
}
func scanDir(rootDir string, maxDepth int) (findings, error) {
var f findings
err := filepath.WalkDir(rootDir, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return nil //nolint:nilerr // skip unreadable entries; returning err would abort the walk
}
if path == rootDir {
return nil // skip root itself
}
rel, _ := filepath.Rel(rootDir, path)
depth := len(strings.Split(rel, string(filepath.Separator)))
relFwd := filepath.ToSlash(rel)
if d.IsDir() {
if skipDirs[d.Name()] {
return filepath.SkipDir
}
if depth >= maxDepth {
return filepath.SkipDir
}
return nil
}
// File: classify by extension then by base name.
name := d.Name()
base := strings.ToLower(name)
ext := strings.ToLower(filepath.Ext(name))
switch ext {
case ".sln":
f.slnFiles = append(f.slnFiles, relFwd)
case ".csproj", ".fsproj", ".vbproj":
f.csprojFiles = append(f.csprojFiles, relFwd)
case ".tsx", ".jsx":
f.hasTSXorJSX = true
}
switch base {
case "global.json":
f.hasGlobalJSON = true
case "package.json":
f.packageJSONs = append(f.packageJSONs, parsePackageJSON(path, relFwd))
case "go.mod":
if depth == 1 {
f.hasGoModRoot = true
}
case "pyproject.toml":
f.hasPyProject = true
case "requirements.txt":
f.hasReqTxt = true
case "setup.py":
f.hasSetupPy = true
}
return nil
})
return f, err
}
// pkgJSONSchema is the minimal structure needed to check for specific deps.
type pkgJSONSchema struct {
Dependencies map[string]json.RawMessage `json:"dependencies"`
DevDependencies map[string]json.RawMessage `json:"devDependencies"`
}
func parsePackageJSON(path, relPath string) pkgJSON {
result := pkgJSON{relPath: relPath}
b, err := os.ReadFile(path)
if err != nil {
return result
}
var raw pkgJSONSchema
if err := json.Unmarshal(b, &raw); err != nil {
return result
}
allDeps := make(map[string]bool, len(raw.Dependencies)+len(raw.DevDependencies))
for k := range raw.Dependencies {
allDeps[k] = true
}
for k := range raw.DevDependencies {
allDeps[k] = true
}
result.hasReact = allDeps["react"]
result.hasTypeScript = allDeps["typescript"] || allDeps["@types/react"]
return result
}
// buildStacks applies heuristics to the findings and returns stacks sorted
// by confidence descending.
func buildStacks(f findings) []Stack {
var stacks []Stack
// C# — high if .sln or .csproj present; medium if only global.json.
if len(f.slnFiles) > 0 || len(f.csprojFiles) > 0 {
var evidence []string
for _, s := range f.slnFiles {
evidence = append(evidence, evidenceItem(s))
}
for i, s := range f.csprojFiles {
if i >= 3 {
evidence = append(evidence, fmt.Sprintf("...and %d more .csproj files", len(f.csprojFiles)-3))
break
}
evidence = append(evidence, evidenceItem(s))
}
stacks = append(stacks, Stack{Name: "csharp", Confidence: "high", Evidence: evidence})
} else if f.hasGlobalJSON {
stacks = append(stacks, Stack{
Name: "csharp", Confidence: "medium",
Evidence: []string{"found: `global.json`"},
})
}
// React — high if package.json lists react; medium if TypeScript + tsx/jsx files.
reactFound := false
for _, pkg := range f.packageJSONs {
if pkg.hasReact {
stacks = append(stacks, Stack{
Name: "react",
Confidence: "high",
Evidence: []string{fmt.Sprintf("found: `%s` with `react` in dependencies", pkg.relPath)},
})
reactFound = true
break
}
}
if !reactFound {
for _, pkg := range f.packageJSONs {
if pkg.hasTypeScript && f.hasTSXorJSX {
stacks = append(stacks, Stack{
Name: "react",
Confidence: "medium",
Evidence: []string{fmt.Sprintf("found: `%s` with TypeScript + `.tsx`/`.jsx` files", pkg.relPath)},
})
break
}
}
}
// Go — high if go.mod is at the root (not nested).
if f.hasGoModRoot {
stacks = append(stacks, Stack{
Name: "go", Confidence: "high",
Evidence: []string{"found: `go.mod`"},
})
}
// Python — medium if any standard config file is present.
if f.hasPyProject || f.hasReqTxt || f.hasSetupPy {
var evidence []string
if f.hasPyProject {
evidence = append(evidence, "found: `pyproject.toml`")
}
if f.hasReqTxt {
evidence = append(evidence, "found: `requirements.txt`")
}
if f.hasSetupPy {
evidence = append(evidence, "found: `setup.py`")
}
stacks = append(stacks, Stack{Name: "python", Confidence: "medium", Evidence: evidence})
}
sort.SliceStable(stacks, func(i, j int) bool {
return confidenceRank(stacks[i].Confidence) > confidenceRank(stacks[j].Confidence)
})
return stacks
}
// evidenceItem formats a relative path for evidence display.
// Root-level files get an "at root" suffix.
func evidenceItem(relFwd string) string {
if !strings.Contains(relFwd, "/") {
return fmt.Sprintf("found: `%s` at root", relFwd)
}
return fmt.Sprintf("found: `%s`", relFwd)
}
func confidenceRank(c string) int {
switch c {
case "high":
return 3
case "medium":
return 2
case "low":
return 1
default:
return 0
}
}

View File

@ -1,10 +1,7 @@
// Package csharp implements the ctx csharp plugin. // Package csharp implements the ctx csharp plugin — .NET solution analysis via Roslyn helper.
// Full implementation: prompts 46 (requires Roslyn helper from prompt 3).
package csharp package csharp
import ( import (
"fmt"
"github.com/ricarneiro/ctx/internal/core" "github.com/ricarneiro/ctx/internal/core"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
@ -16,16 +13,20 @@ func init() {
type csharpPlugin struct{} type csharpPlugin struct{}
func (c *csharpPlugin) Name() string { return "csharp" } func (c *csharpPlugin) Name() string { return "csharp" }
func (c *csharpPlugin) Version() string { return "0.0.1" } func (c *csharpPlugin) Version() string { return "0.1.0" }
func (c *csharpPlugin) ShortDescription() string { return "C# / .NET project analysis via Roslyn" } func (c *csharpPlugin) ShortDescription() string { return "C# / .NET project analysis via Roslyn" }
func (c *csharpPlugin) Command(ctx *core.Context) *cobra.Command { func (c *csharpPlugin) Command(ctx *core.Context) *cobra.Command {
return &cobra.Command{ cmd := &cobra.Command{
Use: "csharp", Use: "csharp",
Short: c.ShortDescription(), Short: c.ShortDescription(),
RunE: func(cmd *cobra.Command, args []string) error { Long: `Analyze C# / .NET projects and emit compact markdown summaries
fmt.Fprintln(ctx.Stderr, "Not implemented yet — coming in prompt 4") optimized for Claude Code consumption.
return nil
}, Requires the Roslyn helper (ctx-roslyn-helper) to be built.
See 'ctx csharp project --help' for details.`,
} }
cmd.AddCommand(projectCmd(ctx))
cmd.AddCommand(outlineCmd(ctx))
return cmd
} }

View File

@ -0,0 +1,425 @@
package csharp
import (
"fmt"
"io"
"path/filepath"
"strings"
"github.com/ricarneiro/ctx/internal/output"
"github.com/ricarneiro/ctx/internal/plugins/csharp/helper"
)
// WriteSummary formats a ProjectSummary as dense markdown.
func WriteSummary(w io.Writer, s *helper.ProjectSummary) error {
output.H1(w, "Solution: "+s.SolutionName)
writeOverview(w, s)
output.H2(w, "Projects")
writeProjects(w, s.Projects)
writeReferenceGraph(w, s.Projects)
writeMultiTargeting(w, s.Projects)
return nil
}
func writeOverview(w io.Writer, s *helper.ProjectSummary) {
exeCount, libCount := 0, 0
totalDocs := 0
for _, p := range s.Projects {
if p.Type == "exe" {
exeCount++
} else {
libCount++
}
totalDocs += p.DocumentCount
}
projectSummary := fmt.Sprintf("%d", len(s.Projects))
parts := []string{}
if exeCount > 0 {
parts = append(parts, fmt.Sprintf("%d exe", exeCount))
}
if libCount > 0 {
parts = append(parts, fmt.Sprintf("%d lib", libCount))
}
if len(parts) > 0 {
projectSummary += " (" + strings.Join(parts, ", ") + ")"
}
output.KeyValue(w, "path", "`"+s.SolutionPath+"`")
output.KeyValue(w, "projects", projectSummary)
output.KeyValue(w, "documents", fmt.Sprintf("%d", totalDocs))
fmt.Fprintln(w)
}
func writeProjects(w io.Writer, projects []helper.ProjectInfo) {
for _, p := range projects {
output.H3(w, fmt.Sprintf("%s (%s)", p.Name, p.Type))
fmt.Fprintf(w, "- **path:** `%s`\n", p.Path)
targets := strings.Join(p.TargetFrameworks, ", ")
if targets == "" {
targets = "_(unknown)_"
}
fmt.Fprintf(w, "- **target:** %s\n", targets)
fmt.Fprintf(w, "- **namespace:** `%s`\n", p.RootNamespace)
fmt.Fprintf(w, "- **documents:** %d\n", p.DocumentCount)
// Project references
if len(p.ProjectReferences) == 0 {
fmt.Fprintf(w, "- **references:** _(none)_\n")
} else {
refs := make([]string, len(p.ProjectReferences))
for i, r := range p.ProjectReferences {
refs[i] = "`" + r + "`"
}
fmt.Fprintf(w, "- **references:**\n")
for _, r := range refs {
fmt.Fprintf(w, " - %s\n", r)
}
}
// Package references
if len(p.PackageReferences) == 0 {
fmt.Fprintf(w, "- **packages:** _(none)_\n")
} else {
pkgs := make([]string, len(p.PackageReferences))
for i, pkg := range p.PackageReferences {
pkgs[i] = pkg.Name + " " + pkg.Version
}
fmt.Fprintf(w, "- **packages:** %s\n", strings.Join(pkgs, ", "))
}
fmt.Fprintln(w)
}
}
func writeReferenceGraph(w io.Writer, projects []helper.ProjectInfo) {
// Build reverse dependency map: who depends on each project
dependents := map[string][]string{}
refMap := map[string][]string{}
nameSet := map[string]bool{}
for _, p := range projects {
nameSet[p.Name] = true
refMap[p.Name] = p.ProjectReferences
for _, ref := range p.ProjectReferences {
dependents[ref] = append(dependents[ref], p.Name)
}
}
// Check if there are any references at all
hasRefs := false
for _, p := range projects {
if len(p.ProjectReferences) > 0 {
hasRefs = true
break
}
}
output.H2(w, "Reference graph")
if !hasRefs {
fmt.Fprintf(w, "No inter-project references.\n\n")
return
}
// For complex graphs (>5 projects with references), use flat list
refsCount := 0
for _, p := range projects {
if len(p.ProjectReferences) > 0 {
refsCount++
}
}
if len(projects) > 5 && refsCount > 3 {
for _, p := range projects {
if len(p.ProjectReferences) > 0 {
refs := make([]string, len(p.ProjectReferences))
for i, r := range p.ProjectReferences {
refs[i] = "`" + r + "`"
}
fmt.Fprintf(w, "- `%s` → %s\n", p.Name, strings.Join(refs, ", "))
}
}
fmt.Fprintln(w)
return
}
// DFS from roots (projects with no dependents)
roots := []string{}
for _, p := range projects {
if len(dependents[p.Name]) == 0 {
roots = append(roots, p.Name)
}
}
if len(roots) == 0 {
// Cycle or all have dependents — fall back to flat list
for _, p := range projects {
if len(p.ProjectReferences) > 0 {
refs := make([]string, len(p.ProjectReferences))
for i, r := range p.ProjectReferences {
refs[i] = "`" + r + "`"
}
fmt.Fprintf(w, "- `%s` → %s\n", p.Name, strings.Join(refs, ", "))
}
}
fmt.Fprintln(w)
return
}
var sb strings.Builder
visited := map[string]bool{}
for _, root := range roots {
dfsRender(&sb, root, refMap, visited, 0)
}
output.CodeBlock(w, "", strings.TrimRight(sb.String(), "\n"))
}
func dfsRender(sb *strings.Builder, name string, refMap map[string][]string, visited map[string]bool, depth int) {
indent := strings.Repeat(" ", depth)
refs := refMap[name]
if len(refs) == 0 {
if depth == 0 {
fmt.Fprintf(sb, "%s%s (no deps)\n", indent, name)
} else {
fmt.Fprintf(sb, "%s%s\n", indent, name)
}
return
}
if visited[name] {
fmt.Fprintf(sb, "%s%s (see above)\n", indent, name)
return
}
visited[name] = true
for i, ref := range refs {
if i == 0 {
fmt.Fprintf(sb, "%s%s → %s\n", indent, name, ref)
} else {
fmt.Fprintf(sb, "%s%s └──→ %s\n", indent, strings.Repeat(" ", len(name)), ref)
}
dfsRender(sb, ref, refMap, visited, depth+1)
}
}
func writeMultiTargeting(w io.Writer, projects []helper.ProjectInfo) {
output.H2(w, "Multi-targeting")
// Collect all unique frameworks
frameworkSets := map[string][]string{} // project name → frameworks
allFrameworks := map[string]bool{}
for _, p := range projects {
if len(p.TargetFrameworks) > 1 {
frameworkSets[p.Name] = p.TargetFrameworks
}
for _, tf := range p.TargetFrameworks {
allFrameworks[tf] = true
}
}
if len(frameworkSets) == 0 {
// All same framework, or single framework each
switch len(allFrameworks) {
case 0:
fmt.Fprintf(w, "No target frameworks detected.\n\n")
case 1:
for tf := range allFrameworks {
fmt.Fprintf(w, "None — all projects target `%s`.\n\n", tf)
}
default:
// Multiple different single targets
for _, p := range projects {
if len(p.TargetFrameworks) > 0 {
fmt.Fprintf(w, "- `%s` targets: `%s`\n", p.Name, strings.Join(p.TargetFrameworks, "`, `"))
}
}
fmt.Fprintln(w)
}
return
}
for _, p := range projects {
if tfs, ok := frameworkSets[p.Name]; ok {
fmt.Fprintf(w, "- `%s` targets: `%s`\n", p.Name, strings.Join(tfs, "`, `"))
}
}
fmt.Fprintln(w)
}
// ─── Outline formatter ───────────────────────────────────────────────────────
// WriteOutline formats an OutlineResult as dense markdown.
func WriteOutline(w io.Writer, o *helper.OutlineResult) error {
fileName := filepath.Base(o.Path)
output.H1(w, "Outline: "+fileName)
if o.HasSyntaxErrors {
fmt.Fprintf(w, "> ⚠️ File has syntax errors — outline may be incomplete.\n\n")
}
// Overview
typeSummary := outlineTypesSummary(o.Types)
output.KeyValue(w, "path", "`"+o.Path+"`")
if o.Namespace != "" {
output.KeyValue(w, "namespace", "`"+o.Namespace+"`")
}
output.KeyValue(w, "lines", fmt.Sprintf("%d", o.LineCount))
output.KeyValue(w, "types", typeSummary)
fmt.Fprintln(w)
// Usings
if len(o.Usings) > 0 {
output.H2(w, "Usings")
for _, u := range o.Usings {
fmt.Fprintf(w, "- `%s`\n", u)
}
fmt.Fprintln(w)
}
// Types
if len(o.Types) > 0 {
output.H2(w, "Types")
for i := range o.Types {
writeOutlineType(w, &o.Types[i], 3)
}
}
return nil
}
func outlineTypesSummary(types []helper.OutlineType) string {
counts := map[string]int{}
for _, t := range types {
counts[t.Kind]++
}
if len(counts) == 0 {
return "none"
}
// Fixed display order
order := []string{"class", "interface", "struct", "record", "record struct", "enum"}
parts := []string{}
for _, k := range order {
if n, ok := counts[k]; ok {
parts = append(parts, fmt.Sprintf("%d %s", n, k))
delete(counts, k)
}
}
// Any remaining unknown kinds
for k, n := range counts {
parts = append(parts, fmt.Sprintf("%d %s", n, k))
}
return strings.Join(parts, ", ")
}
func writeOutlineType(w io.Writer, t *helper.OutlineType, headingLevel int) {
// Build header: `kind Name : Base1, Base2` (modifiers)
header := t.Kind + " " + t.Name
if len(t.BaseTypes) > 0 {
header += " : " + strings.Join(t.BaseTypes, ", ")
}
heading := "`" + header + "`"
if len(t.Modifiers) > 0 {
heading += " (" + strings.Join(t.Modifiers, ", ") + ")"
}
writeHeading(w, headingLevel, heading)
// Group members by kind, in canonical order
writeOutlineMembers(w, t.Members, headingLevel+1)
// Nested types — shown as bullet list for simplicity
if len(t.Nested) > 0 {
writeHeading(w, headingLevel+1, "Nested types")
for _, n := range t.Nested {
nestedHeader := n.Kind + " " + n.Name
if len(n.BaseTypes) > 0 {
nestedHeader += " : " + strings.Join(n.BaseTypes, ", ")
}
prefix := modPrefix(n.Modifiers)
fmt.Fprintf(w, "- `%s%s`\n", prefix, nestedHeader)
}
fmt.Fprintln(w)
}
}
func writeOutlineMembers(w io.Writer, members []helper.OutlineMember, headingLevel int) {
// Collect by kind
var fields, constructors, properties, methods, events []helper.OutlineMember
for _, m := range members {
switch m.Kind {
case "field":
fields = append(fields, m)
case "constructor":
constructors = append(constructors, m)
case "property":
properties = append(properties, m)
case "method":
methods = append(methods, m)
case "event":
events = append(events, m)
}
}
if len(fields) > 0 {
writeHeading(w, headingLevel, "Fields")
for _, m := range fields {
writeMemberLine(w, m)
}
fmt.Fprintln(w)
}
if len(constructors) > 0 {
writeHeading(w, headingLevel, "Constructor")
for _, m := range constructors {
writeMemberLine(w, m)
}
fmt.Fprintln(w)
}
if len(properties) > 0 {
writeHeading(w, headingLevel, "Properties")
for _, m := range properties {
writeMemberLine(w, m)
}
fmt.Fprintln(w)
}
if len(methods) > 0 {
writeHeading(w, headingLevel, "Methods")
for _, m := range methods {
writeMemberLine(w, m)
}
fmt.Fprintln(w)
}
if len(events) > 0 {
writeHeading(w, headingLevel, "Events")
for _, m := range events {
writeMemberLine(w, m)
}
fmt.Fprintln(w)
}
}
func writeMemberLine(w io.Writer, m helper.OutlineMember) {
prefix := modPrefix(m.Modifiers)
obsolete := ""
if m.IsObsolete {
obsolete = " _(obsolete)_"
}
lineRef := ""
if m.Line > 0 {
lineRef = fmt.Sprintf(" (line %d)", m.Line)
}
fmt.Fprintf(w, "- `%s%s`%s%s\n", prefix, m.Signature, lineRef, obsolete)
}
func modPrefix(mods []string) string {
if len(mods) == 0 {
return ""
}
return strings.Join(mods, " ") + " "
}
func writeHeading(w io.Writer, level int, text string) {
fmt.Fprintf(w, "%s %s\n\n", strings.Repeat("#", level), text)
}

View File

@ -0,0 +1,185 @@
package helper
import (
"encoding/json"
"errors"
"fmt"
)
// Client is a high-level typed interface to the roslyn-helper subprocess.
type Client struct {
proc *Process
}
// NewClient locates the helper binary, starts the process, and verifies it
// responds to ping. Returns an error if any step fails.
func NewClient() (*Client, error) {
helperPath, err := LocateHelper()
if err != nil {
return nil, err
}
proc, err := Start(helperPath)
if err != nil {
return nil, fmt.Errorf("start roslyn helper: %w", err)
}
c := &Client{proc: proc}
if _, err := c.Ping(); err != nil {
_ = proc.Close()
return nil, fmt.Errorf("roslyn helper ping failed: %w", err)
}
return c, nil
}
// Close shuts down the helper process.
func (c *Client) Close() error {
return c.proc.Close()
}
// --- Result types ---
// PingResult is returned by the ping method.
type PingResult struct {
Pong bool `json:"pong"`
Version string `json:"version"`
}
// LoadSolutionResult is returned by the loadSolution method.
type LoadSolutionResult struct {
Loaded bool `json:"loaded"`
ProjectCount int `json:"projectCount"`
DocumentCount int `json:"documentCount"`
}
// ProjectSummary is the top-level result of the projectSummary method.
type ProjectSummary struct {
SolutionPath string `json:"solutionPath"`
SolutionName string `json:"solutionName"`
Projects []ProjectInfo `json:"projects"`
}
// ProjectInfo describes a single project in the solution.
type ProjectInfo struct {
Name string `json:"name"`
Path string `json:"path"`
Type string `json:"type"`
TargetFrameworks []string `json:"targetFrameworks"`
OutputType string `json:"outputType"`
RootNamespace string `json:"rootNamespace"`
DocumentCount int `json:"documentCount"`
ProjectReferences []string `json:"projectReferences"`
PackageReferences []PackageReference `json:"packageReferences"`
}
// PackageReference is a NuGet package dependency.
type PackageReference struct {
Name string `json:"name"`
Version string `json:"version"`
}
// --- Methods ---
// Ping sends a ping to the helper and returns the pong result.
func (c *Client) Ping() (*PingResult, error) {
raw, err := c.proc.Send("ping", nil)
if err != nil {
return nil, wrapRPC("ping", err)
}
var r PingResult
if err := json.Unmarshal(raw, &r); err != nil {
return nil, fmt.Errorf("ping: decode response: %w", err)
}
return &r, nil
}
// LoadSolution instructs the helper to load the given .sln or .csproj file.
func (c *Client) LoadSolution(path string) (*LoadSolutionResult, error) {
params := map[string]string{"path": path}
raw, err := c.proc.Send("loadSolution", params)
if err != nil {
return nil, wrapRPC("loadSolution", err)
}
var r LoadSolutionResult
if err := json.Unmarshal(raw, &r); err != nil {
return nil, fmt.Errorf("loadSolution: decode response: %w", err)
}
return &r, nil
}
// ProjectSummary retrieves the project summary for the currently loaded solution.
func (c *Client) ProjectSummary() (*ProjectSummary, error) {
raw, err := c.proc.Send("projectSummary", nil)
if err != nil {
return nil, wrapRPC("projectSummary", err)
}
var r ProjectSummary
if err := json.Unmarshal(raw, &r); err != nil {
return nil, fmt.Errorf("projectSummary: decode response: %w", err)
}
return &r, nil
}
// --- Outline types ---
// OutlineResult is the structural outline of a single .cs file.
type OutlineResult struct {
Path string `json:"path"`
Namespace string `json:"namespace"`
LineCount int `json:"lineCount"`
Usings []string `json:"usings"`
Types []OutlineType `json:"types"`
HasSyntaxErrors bool `json:"hasSyntaxErrors"`
}
// OutlineType describes a type (class, interface, struct, record, enum) in the file.
type OutlineType struct {
Kind string `json:"kind"`
Name string `json:"name"`
Modifiers []string `json:"modifiers"`
BaseTypes []string `json:"baseTypes"`
Members []OutlineMember `json:"members"`
Nested []OutlineType `json:"nested"`
}
// OutlineMember describes a member of a type (method, property, field, event, constructor).
type OutlineMember struct {
Kind string `json:"kind"`
Signature string `json:"signature"`
Modifiers []string `json:"modifiers"`
Line int `json:"line"`
IsObsolete bool `json:"isObsolete,omitempty"`
}
// Outline requests a structural outline of the given .cs file.
// Does not require a solution to be loaded.
func (c *Client) Outline(path string) (*OutlineResult, error) {
params := map[string]string{"path": path}
raw, err := c.proc.Send("outline", params)
if err != nil {
return nil, wrapRPC("outline", err)
}
var r OutlineResult
if err := json.Unmarshal(raw, &r); err != nil {
return nil, fmt.Errorf("outline: decode response: %w", err)
}
return &r, nil
}
// wrapRPC wraps RPCError values into user-friendly messages.
func wrapRPC(method string, err error) error {
var rpcErr *RPCError
if errors.As(err, &rpcErr) {
switch rpcErr.Code {
case "E_NOT_FOUND":
return fmt.Errorf("solution not found: %s", rpcErr.Message)
case "E_LOAD_FAILED":
return fmt.Errorf("failed to load solution: %s", rpcErr.Message)
case "E_INVALID_PARAMS":
return fmt.Errorf("invalid request: %s", rpcErr.Message)
default:
return fmt.Errorf("%s failed: %s", method, rpcErr.Message)
}
}
return err
}

View File

@ -0,0 +1,86 @@
package helper
import (
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
)
const helperBinary = "ctx-roslyn-helper"
// ErrHelperNotFound is returned when the Roslyn helper binary cannot be located.
var ErrHelperNotFound = errors.New("roslyn helper not found")
// LocateHelper searches for the ctx-roslyn-helper binary using four strategies:
// 1. CTX_ROSLYN_HELPER environment variable
// 2. Same directory as the running ctx binary
// 3. PATH
// 4. tools/roslyn-helper/publish/ relative to working directory
//
// Returns the absolute path or ErrHelperNotFound with a diagnostic message.
func LocateHelper() (string, error) {
name := helperBinary
if runtime.GOOS == "windows" {
name += ".exe"
}
tried := []string{}
// 1. Environment variable
if env := os.Getenv("CTX_ROSLYN_HELPER"); env != "" {
if fileExists(env) {
return env, nil
}
tried = append(tried, fmt.Sprintf("$CTX_ROSLYN_HELPER = %s (not found)", env))
} else {
tried = append(tried, "$CTX_ROSLYN_HELPER (not set)")
}
// 2. Same directory as ctx binary
if exePath, err := os.Executable(); err == nil {
exeDir := filepath.Dir(exePath)
candidate := filepath.Join(exeDir, name)
if fileExists(candidate) {
return candidate, nil
}
tried = append(tried, fmt.Sprintf("%s (not found)", exeDir))
}
// 3. PATH
if found, err := exec.LookPath(name); err == nil {
return found, nil
}
tried = append(tried, "PATH (not found)")
// 4. Dev fallback: tools/roslyn-helper/publish/ relative to working dir
if cwd, err := os.Getwd(); err == nil {
candidate := filepath.Join(cwd, "tools", "roslyn-helper", "publish", name)
if fileExists(candidate) {
return candidate, nil
}
tried = append(tried, fmt.Sprintf("tools/roslyn-helper/publish/ (not found)"))
}
return "", fmt.Errorf("%w\n\nLooked in:\n - %s\n - %s\n - %s\n - %s\n\nTo build the helper, run:\n cd tools\\roslyn-helper\n dotnet publish src/RoslynHelper -c Release -r win-x64 --self-contained false -o publish/",
ErrHelperNotFound,
safeIdx(tried, 0, "$CTX_ROSLYN_HELPER (not set)"),
safeIdx(tried, 1, "ctx.exe directory"),
safeIdx(tried, 2, "PATH"),
safeIdx(tried, 3, "tools/roslyn-helper/publish/"),
)
}
func fileExists(path string) bool {
info, err := os.Stat(path)
return err == nil && !info.IsDir()
}
func safeIdx(s []string, i int, fallback string) string {
if i < len(s) {
return s[i]
}
return fallback
}

View File

@ -0,0 +1,108 @@
package helper
import (
"bufio"
"encoding/json"
"fmt"
"io"
"os/exec"
"sync/atomic"
)
// Process manages the lifetime of the roslyn-helper subprocess.
// Calls are sequential — no concurrency within a single Process.
type Process struct {
cmd *exec.Cmd
stdin io.WriteCloser
stdout *bufio.Reader
nextID atomic.Int64
}
// Start launches the helper subprocess and returns a Process ready to use.
func Start(helperPath string) (*Process, error) {
cmd := exec.Command(helperPath)
stdin, err := cmd.StdinPipe()
if err != nil {
return nil, fmt.Errorf("helper stdin pipe: %w", err)
}
stdoutPipe, err := cmd.StdoutPipe()
if err != nil {
return nil, fmt.Errorf("helper stdout pipe: %w", err)
}
stderrPipe, err := cmd.StderrPipe()
if err != nil {
return nil, fmt.Errorf("helper stderr pipe: %w", err)
}
if err := cmd.Start(); err != nil {
return nil, fmt.Errorf("helper start: %w", err)
}
// Drain stderr in background to prevent blocking.
go func() { _, _ = io.Copy(io.Discard, stderrPipe) }()
p := &Process{
cmd: cmd,
stdin: stdin,
stdout: bufio.NewReader(stdoutPipe),
}
return p, nil
}
// Send sends a JSON-RPC request and returns the raw result JSON.
// Returns an error if the helper returns an RpcError or dies.
func (p *Process) Send(method string, params interface{}) (json.RawMessage, error) {
id := int(p.nextID.Add(1))
var rawParams json.RawMessage
if params != nil {
var err error
rawParams, err = json.Marshal(params)
if err != nil {
return nil, fmt.Errorf("marshal params: %w", err)
}
}
req := Request{ID: id, Method: method, Params: rawParams}
line, err := json.Marshal(req)
if err != nil {
return nil, fmt.Errorf("marshal request: %w", err)
}
line = append(line, '\n')
if _, werr := p.stdin.Write(line); werr != nil {
return nil, fmt.Errorf("helper write (process may have crashed): %w", werr)
}
respLine, err := p.stdout.ReadString('\n')
if err != nil {
return nil, fmt.Errorf("helper read (process may have crashed): %w", err)
}
var resp Response
if err := json.Unmarshal([]byte(respLine), &resp); err != nil {
return nil, fmt.Errorf("unmarshal response: %w", err)
}
if resp.ID != id {
return nil, fmt.Errorf("response id mismatch: got %d, want %d", resp.ID, id)
}
if resp.Error != nil {
return nil, resp.Error
}
return resp.Result, nil
}
// Close sends a shutdown request, closes stdin, and waits for the process to exit.
func (p *Process) Close() error {
// Best-effort shutdown — ignore errors here.
_, _ = p.Send("shutdown", nil)
_ = p.stdin.Close()
_ = p.cmd.Wait()
return nil
}

View File

@ -0,0 +1,26 @@
package helper
import "encoding/json"
// Request is a newline-delimited JSON-RPC request sent to the helper process.
type Request struct {
ID int `json:"id"`
Method string `json:"method"`
Params json.RawMessage `json:"params"`
}
// Response is a newline-delimited JSON-RPC response from the helper process.
type Response struct {
ID int `json:"id"`
Result json.RawMessage `json:"result,omitempty"`
Error *RPCError `json:"error,omitempty"`
}
// RpcError is a structured error from the helper process.
type RPCError struct {
Code string `json:"code"`
Message string `json:"message"`
Data json.RawMessage `json:"data,omitempty"`
}
func (e *RPCError) Error() string { return e.Code + ": " + e.Message }

View File

@ -0,0 +1,68 @@
package csharp
import (
"fmt"
"os"
"path/filepath"
"strings"
"github.com/ricarneiro/ctx/internal/core"
"github.com/ricarneiro/ctx/internal/plugins/csharp/helper"
"github.com/spf13/cobra"
)
func outlineCmd(ctx *core.Context) *cobra.Command {
return &cobra.Command{
Use: "outline <file.cs>",
Short: "Show structural outline of a C# file (no method bodies)",
Long: `Parse a C# source file and emit its structural skeleton:
namespaces, types, method signatures, properties, fields, events.
Method bodies are omitted reduces large files by 8090%.
Does not require a loaded solution. Works on a single file.`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
return runOutline(ctx, args[0])
},
}
}
func runOutline(ctx *core.Context, file string) error {
abs, err := resolveFilePath(ctx.WorkDir, file)
if err != nil {
fmt.Fprintln(ctx.Stderr, err.Error())
return errExit
}
if !strings.HasSuffix(strings.ToLower(abs), ".cs") {
fmt.Fprintf(ctx.Stderr, "not a C# file: %s\n", abs)
return errExit
}
if _, statErr := os.Stat(abs); statErr != nil {
fmt.Fprintf(ctx.Stderr, "file not found: %s\n", abs)
return errExit
}
client, err := helper.NewClient()
if err != nil {
fmt.Fprintln(ctx.Stderr, err.Error())
return errExit
}
defer client.Close()
outline, err := client.Outline(abs)
if err != nil {
fmt.Fprintln(ctx.Stderr, err.Error())
return errExit
}
return WriteOutline(ctx.Stdout, outline)
}
func resolveFilePath(workDir, file string) (string, error) {
if filepath.IsAbs(file) {
return filepath.Clean(file), nil
}
return filepath.Clean(filepath.Join(workDir, file)), nil
}

View File

@ -0,0 +1,111 @@
package csharp
import (
"fmt"
"os"
"path/filepath"
"sort"
"github.com/ricarneiro/ctx/internal/core"
"github.com/ricarneiro/ctx/internal/plugins/csharp/helper"
"github.com/spf13/cobra"
)
// errExit is a sentinel used to trigger os.Exit(1) via the SilenceErrors flow.
// The real error message has already been printed to ctx.Stderr.
var errExit = fmt.Errorf("exit 1")
func projectCmd(ctx *core.Context) *cobra.Command {
return &cobra.Command{
Use: "project",
Short: "Summarize the .NET solution in compact markdown",
Long: `Analyze the .NET solution in the current directory and emit a compact
markdown summary suitable for Claude Code consumption.
Requires the Roslyn helper (ctx-roslyn-helper) to be built and accessible.
Set CTX_ROSLYN_HELPER to the exact path, or build it alongside ctx.exe.`,
RunE: func(cmd *cobra.Command, args []string) error {
return runProject(ctx)
},
}
}
func runProject(ctx *core.Context) error {
slnPath, err := findSolution(ctx.WorkDir, ctx)
if err != nil {
fmt.Fprintln(ctx.Stderr, err.Error())
return errExit
}
client, err := helper.NewClient()
if err != nil {
fmt.Fprintln(ctx.Stderr, err.Error())
return errExit
}
defer client.Close()
if _, loadErr := client.LoadSolution(slnPath); loadErr != nil {
fmt.Fprintln(ctx.Stderr, loadErr.Error())
return errExit
}
summary, err := client.ProjectSummary()
if err != nil {
fmt.Fprintln(ctx.Stderr, err.Error())
return errExit
}
return WriteSummary(ctx.Stdout, summary)
}
// findSolution locates a .sln file in dir.
// Falls back to a single .csproj if no .sln exists.
func findSolution(dir string, ctx *core.Context) (string, error) {
slns, err := filepath.Glob(filepath.Join(dir, "*.sln"))
if err != nil {
return "", fmt.Errorf("glob .sln: %w", err)
}
// Filter to files that actually exist
slns = existingFiles(slns)
if len(slns) > 0 {
sort.Strings(slns)
if len(slns) > 1 {
fmt.Fprintf(ctx.Stderr, "warning: multiple .sln files found, using %s\n", filepath.Base(slns[0]))
}
return slns[0], nil
}
// Fallback: single .csproj
csprojPaths, err := filepath.Glob(filepath.Join(dir, "*.csproj"))
if err != nil {
return "", fmt.Errorf("glob .csproj: %w", err)
}
csprojPaths = existingFiles(csprojPaths)
switch len(csprojPaths) {
case 0:
return "", fmt.Errorf("no .sln or .csproj found in %s", dir)
case 1:
return csprojPaths[0], nil
default:
return "", fmt.Errorf(
"no .sln found and multiple .csproj files exist in %s\n"+
"Run ctx inside a specific project folder, or create a .sln:\n"+
" dotnet new sln --format sln\n"+
" dotnet sln add **/*.csproj",
dir,
)
}
}
func existingFiles(paths []string) []string {
out := paths[:0]
for _, p := range paths {
if _, err := os.Stat(p); err == nil {
out = append(out, p)
}
}
return out
}

View File

@ -0,0 +1,292 @@
package git
import (
"bytes"
"fmt"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
)
// --- Data types ---
type repoInfo struct {
branch string
upstream string // "origin/main" or "" if no upstream
ahead int
behind int
lastFetch time.Time
hasFetch bool
}
type commit struct {
hash string
unixTs int64
author string
subject string
}
type fileChange struct {
path string
added int
removed int
}
type workingTree struct {
modified []fileChange // unstaged (git diff --numstat)
staged []fileChange // staged (git diff --cached --numstat)
untracked []string
}
type gitData struct {
repo repoInfo
commits []commit
tree workingTree
}
// --- Git runner ---
// gitCmd runs a git command in dir and returns trimmed stdout.
// Returns an error if git exits non-zero.
func gitCmd(dir string, args ...string) (string, error) {
cmd := exec.Command("git", args...)
cmd.Dir = dir
var out, errBuf bytes.Buffer
cmd.Stdout = &out
cmd.Stderr = &errBuf
if err := cmd.Run(); err != nil {
return "", fmt.Errorf("git %s: %w", strings.Join(args, " "), err)
}
return strings.TrimRight(out.String(), "\r\n"), nil
}
// --- Top-level collector ---
// collect gathers all git data in parallel. Returns a friendly error if dir
// is not inside a git repository.
func collect(dir string) (*gitData, error) {
// Verify we're in a repo before spawning goroutines.
if _, err := gitCmd(dir, "rev-parse", "--is-inside-work-tree"); err != nil {
return nil, fmt.Errorf("not a git repository (run from inside a git repo)")
}
var (
data gitData
mu sync.Mutex
wg sync.WaitGroup
errs []error
)
record := func(err error) {
if err != nil {
mu.Lock()
errs = append(errs, err)
mu.Unlock()
}
}
wg.Add(3)
go func() {
defer wg.Done()
info, err := collectRepoInfo(dir)
record(err)
if err == nil {
mu.Lock()
data.repo = info
mu.Unlock()
}
}()
go func() {
defer wg.Done()
commits, err := collectCommits(dir, 5)
record(err)
if err == nil {
mu.Lock()
data.commits = commits
mu.Unlock()
}
}()
go func() {
defer wg.Done()
tree, err := collectWorkingTree(dir)
record(err)
if err == nil {
mu.Lock()
data.tree = tree
mu.Unlock()
}
}()
wg.Wait()
if len(errs) > 0 {
return nil, errs[0]
}
return &data, nil
}
// --- Individual collectors ---
func collectRepoInfo(dir string) (repoInfo, error) {
var info repoInfo
branch, err := gitCmd(dir, "rev-parse", "--abbrev-ref", "HEAD")
if err != nil {
return info, err
}
info.branch = branch
// Ahead/behind vs upstream (fails gracefully if no upstream).
if ab, err := gitCmd(dir, "rev-list", "--left-right", "--count", "HEAD...@{u}"); err == nil {
parts := strings.Fields(ab)
if len(parts) == 2 {
info.ahead, _ = strconv.Atoi(parts[0])
info.behind, _ = strconv.Atoi(parts[1])
if u, err := gitCmd(dir, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"); err == nil {
info.upstream = u
}
}
}
// FETCH_HEAD mtime — find git dir first (handles worktrees, submodules).
if gitDir, err := gitCmd(dir, "rev-parse", "--git-dir"); err == nil {
if !filepath.IsAbs(gitDir) {
gitDir = filepath.Join(dir, gitDir)
}
if fi, err := os.Stat(filepath.Join(gitDir, "FETCH_HEAD")); err == nil {
info.lastFetch = fi.ModTime()
info.hasFetch = true
}
}
return info, nil
}
func collectCommits(dir string, n int) ([]commit, error) {
out, err := gitCmd(dir, "log", fmt.Sprintf("-n%d", n), "--pretty=format:%h|%at|%an|%s")
if err != nil || out == "" {
// Empty repo or no commits — not an error for our purposes.
return nil, nil //nolint:nilerr // intentional: git failures here mean "nothing to show"
}
var commits []commit
for _, line := range strings.Split(out, "\n") {
if line == "" {
continue
}
// SplitN 4 so subject can contain "|".
parts := strings.SplitN(line, "|", 4)
if len(parts) != 4 {
continue
}
ts, _ := strconv.ParseInt(parts[1], 10, 64)
commits = append(commits, commit{
hash: parts[0],
unixTs: ts,
author: parts[2],
subject: parts[3],
})
}
return commits, nil
}
func collectWorkingTree(dir string) (workingTree, error) {
var (
tree workingTree
wg sync.WaitGroup
mu sync.Mutex
errs []error
)
record := func(err error) {
if err != nil {
mu.Lock()
errs = append(errs, err)
mu.Unlock()
}
}
wg.Add(3)
go func() {
defer wg.Done()
changes, err := parseNumstat(dir, false)
record(err)
mu.Lock()
tree.modified = changes
mu.Unlock()
}()
go func() {
defer wg.Done()
changes, err := parseNumstat(dir, true)
record(err)
mu.Lock()
tree.staged = changes
mu.Unlock()
}()
go func() {
defer wg.Done()
untracked, err := parseUntracked(dir)
record(err)
mu.Lock()
tree.untracked = untracked
mu.Unlock()
}()
wg.Wait()
if len(errs) > 0 {
return tree, errs[0]
}
return tree, nil
}
func parseNumstat(dir string, cached bool) ([]fileChange, error) {
args := []string{"diff", "--numstat"}
if cached {
args = append(args, "--cached")
}
out, err := gitCmd(dir, args...)
if err != nil || out == "" {
return nil, nil //nolint:nilerr // no changes or not in a git repo — not an error
}
var changes []fileChange
for _, line := range strings.Split(out, "\n") {
if line == "" {
continue
}
parts := strings.SplitN(line, "\t", 3)
if len(parts) != 3 {
continue
}
// Binary files show "-" — treat as 0.
added, _ := strconv.Atoi(parts[0])
removed, _ := strconv.Atoi(parts[1])
changes = append(changes, fileChange{
path: parts[2],
added: added,
removed: removed,
})
}
return changes, nil
}
func parseUntracked(dir string) ([]string, error) {
out, err := gitCmd(dir, "status", "--porcelain=v1")
if err != nil || out == "" {
return nil, nil //nolint:nilerr // clean working tree or not in a git repo — not an error
}
var untracked []string
for _, line := range strings.Split(out, "\n") {
if len(line) >= 3 && line[0] == '?' && line[1] == '?' {
untracked = append(untracked, strings.TrimSpace(line[3:]))
}
}
return untracked, nil
}

View File

@ -0,0 +1,293 @@
package git
import (
"fmt"
"io"
"path/filepath"
"sort"
"strings"
"time"
)
// formatOutput writes the full markdown report to w.
func formatOutput(w io.Writer, data *gitData) {
fmt.Fprintln(w, "# Git Context")
fmt.Fprintln(w)
writeMeta(w, data)
fmt.Fprintln(w)
writeCommits(w, data.commits)
writeWorkingTree(w, data.tree)
writeDiffSummary(w, data.tree)
}
func writeMeta(w io.Writer, data *gitData) {
// Branch + upstream
branch := data.repo.branch
if data.repo.upstream != "" {
branch += fmt.Sprintf(" (ahead %d, behind %d vs %s)",
data.repo.ahead, data.repo.behind, data.repo.upstream)
}
fmt.Fprintf(w, "**branch:** %s\n", branch)
// Status summary
m, s, u := len(data.tree.modified), len(data.tree.staged), len(data.tree.untracked)
if m == 0 && s == 0 && u == 0 {
fmt.Fprintln(w, "**status:** clean")
} else {
var parts []string
if m > 0 {
parts = append(parts, fmt.Sprintf("%d modified", m))
}
if s > 0 {
parts = append(parts, fmt.Sprintf("%d staged", s))
}
if u > 0 {
parts = append(parts, fmt.Sprintf("%d untracked", u))
}
fmt.Fprintf(w, "**status:** %s\n", strings.Join(parts, ", "))
}
// Last fetched
if data.repo.hasFetch {
fmt.Fprintf(w, "**last fetched:** %s\n", relativeTime(data.repo.lastFetch))
}
}
func writeCommits(w io.Writer, commits []commit) {
if len(commits) == 0 {
return
}
fmt.Fprintf(w, "## Recent commits (last %d)\n", len(commits))
for _, c := range commits {
t := time.Unix(c.unixTs, 0)
fmt.Fprintf(w, "- `%s` (%s, %s) %s\n", c.hash, relativeTime(t), c.author, c.subject)
}
fmt.Fprintln(w)
}
func writeWorkingTree(w io.Writer, tree workingTree) {
if len(tree.modified) == 0 && len(tree.staged) == 0 && len(tree.untracked) == 0 {
return
}
fmt.Fprintln(w, "## Working tree")
if len(tree.modified) > 0 {
fmt.Fprintf(w, "### Modified (%d)\n", len(tree.modified))
for _, f := range tree.modified {
fmt.Fprintf(w, "- `%s` (+%d -%d)\n", f.path, f.added, f.removed)
}
fmt.Fprintln(w)
}
if len(tree.staged) > 0 {
fmt.Fprintf(w, "### Staged (%d)\n", len(tree.staged))
for _, f := range tree.staged {
fmt.Fprintf(w, "- `%s` (+%d -%d)\n", f.path, f.added, f.removed)
}
fmt.Fprintln(w)
}
if len(tree.untracked) > 0 {
fmt.Fprintf(w, "### Untracked (%d)\n", len(tree.untracked))
for _, u := range tree.untracked {
fmt.Fprintf(w, "- `%s`\n", u)
}
fmt.Fprintln(w)
}
}
func writeDiffSummary(w io.Writer, tree workingTree) {
all := mergeChanges(tree.modified, tree.staged)
if len(all) == 0 {
return
}
totalAdded, totalRemoved := 0, 0
for _, f := range all {
totalAdded += f.added
totalRemoved += f.removed
}
fmt.Fprintln(w, "## Diff summary (unstaged + staged)")
fmt.Fprintf(w, "**Total:** +%d -%d across %d file%s\n\n",
totalAdded, totalRemoved, len(all), plural(len(all)))
// By top-level directory (only if files span more than one).
byDir := groupByTopDir(all)
if len(byDir) > 1 {
fmt.Fprintln(w, "### By directory")
dirs := make([]string, 0, len(byDir))
for d := range byDir {
dirs = append(dirs, d)
}
sort.Strings(dirs)
for _, d := range dirs {
s := byDir[d]
label := d + "/"
if d == "." {
label = "root"
}
fmt.Fprintf(w, "- `%s`: +%d -%d\n", label, s.added, s.removed)
}
fmt.Fprintln(w)
}
// Notable changes — top 5 by total lines changed.
notable := topBySize(all, 5)
fmt.Fprintln(w, "### Notable changes")
for _, f := range notable {
kind := classify(f)
fmt.Fprintf(w, "- `%s`: %s (+%d lines) — %s\n", f.path, kind, f.added, reason(kind))
}
if len(all) > 5 {
more := len(all) - 5
fmt.Fprintf(w, "- ...and %d more file%s\n", more, plural(more))
}
}
// --- Helpers ---
type dirStat struct{ added, removed int }
func groupByTopDir(changes []fileChange) map[string]dirStat {
result := make(map[string]dirStat)
for _, f := range changes {
d := topDir(f.path)
s := result[d]
s.added += f.added
s.removed += f.removed
result[d] = s
}
return result
}
// topDir returns the first path component (directory), or "." for root files.
func topDir(path string) string {
// Normalize to forward slashes.
clean := filepath.ToSlash(path)
idx := strings.Index(clean, "/")
if idx == -1 {
return "."
}
return clean[:idx]
}
// mergeChanges deduplicates by path, summing stats for files that appear in both lists.
func mergeChanges(a, b []fileChange) []fileChange {
merged := make(map[string]fileChange)
for _, f := range a {
merged[f.path] = f
}
for _, f := range b {
if existing, ok := merged[f.path]; ok {
existing.added += f.added
existing.removed += f.removed
merged[f.path] = existing
} else {
merged[f.path] = f
}
}
result := make([]fileChange, 0, len(merged))
for _, f := range merged {
result = append(result, f)
}
sort.Slice(result, func(i, j int) bool { return result[i].path < result[j].path })
return result
}
// topBySize returns up to n files sorted by (added+removed) descending.
func topBySize(changes []fileChange, n int) []fileChange {
sorted := make([]fileChange, len(changes))
copy(sorted, changes)
sort.Slice(sorted, func(i, j int) bool {
ti := sorted[i].added + sorted[i].removed
tj := sorted[j].added + sorted[j].removed
return ti > tj
})
if n > len(sorted) {
n = len(sorted)
}
return sorted[:n]
}
func classify(f fileChange) string {
a, r := f.added, f.removed
switch {
case a > 30:
return "large change"
case a+r <= 10:
return "small change"
case r == 0:
return "pure addition"
case a == 0:
return "pure deletion"
case a > 20 && r > 20:
return "rewrite"
default:
if r > 0 {
ratio := float64(a) / float64(r)
if ratio >= 0.5 && ratio <= 2.0 {
return "refactor"
}
}
return "mixed change"
}
}
func reason(kind string) string {
switch kind {
case "large change":
return "likely new feature"
case "small change":
return "likely refactor or tweak"
case "pure addition":
return "new file or additions only"
case "pure deletion":
return "deletions only"
case "rewrite":
return "significant rewrite"
case "refactor":
return "likely refactor"
default:
return "mixed additions and removals"
}
}
// relativeTime returns a human-readable relative duration string.
func relativeTime(t time.Time) string {
d := time.Since(t)
if d < 0 {
d = -d
}
switch {
case d < time.Minute:
return "just now"
case d < time.Hour:
n := int(d.Minutes())
return fmt.Sprintf("%d minute%s ago", n, plural(n))
case d < 24*time.Hour:
n := int(d.Hours())
return fmt.Sprintf("%d hour%s ago", n, plural(n))
case d < 7*24*time.Hour:
n := int(d.Hours() / 24)
return fmt.Sprintf("%d day%s ago", n, plural(n))
case d < 30*24*time.Hour:
n := int(d.Hours() / (24 * 7))
return fmt.Sprintf("%d week%s ago", n, plural(n))
case d < 365*24*time.Hour:
n := int(d.Hours() / (24 * 30))
return fmt.Sprintf("%d month%s ago", n, plural(n))
default:
n := int(d.Hours() / (24 * 365))
return fmt.Sprintf("%d year%s ago", n, plural(n))
}
}
func plural(n int) string {
if n == 1 {
return ""
}
return "s"
}

View File

@ -1,5 +1,4 @@
// Package git implements the ctx git plugin. // Package git implements the ctx git plugin — compact git state summary.
// Full implementation: prompt 1.
package git package git
import ( import (
@ -10,22 +9,36 @@ import (
) )
func init() { func init() {
core.Register(&gitPlugin{}) core.Register(&plugin{})
} }
type gitPlugin struct{} type plugin struct{}
func (g *gitPlugin) Name() string { return "git" } func (p *plugin) Name() string { return "git" }
func (g *gitPlugin) Version() string { return "0.0.1" } func (p *plugin) Version() string { return "0.1.0" }
func (g *gitPlugin) ShortDescription() string { return "Git repository summary for Claude" } func (p *plugin) ShortDescription() string { return "Summarize git state in compact markdown" }
func (g *gitPlugin) Command(ctx *core.Context) *cobra.Command { func (p *plugin) Command(ctx *core.Context) *cobra.Command {
return &cobra.Command{ return &cobra.Command{
Use: "git", Use: "git",
Short: g.ShortDescription(), Short: p.ShortDescription(),
Long: `Emit a compact markdown summary of the current git repository:
branch, ahead/behind upstream, recent commits, working tree changes,
and a diff summary grouped by directory.
Output goes to stdout. Pipe it into Claude or save it to a file.`,
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
fmt.Fprintln(ctx.Stderr, "Not implemented yet — coming in prompt 1") return run(ctx)
return nil
}, },
} }
} }
func run(ctx *core.Context) error {
data, err := collect(ctx.WorkDir)
if err != nil {
fmt.Fprintln(ctx.Stderr, err.Error())
return fmt.Errorf("exit 1") // non-nil → os.Exit(1); SilenceErrors suppresses print
}
formatOutput(ctx.Stdout, data)
return nil
}

View File

@ -23,6 +23,9 @@ func (r *reactPlugin) Command(ctx *core.Context) *cobra.Command {
return &cobra.Command{ return &cobra.Command{
Use: "react", Use: "react",
Short: r.ShortDescription(), Short: r.ShortDescription(),
// placeholder=true tells ctx auto to show a placeholder message instead of
// attempting to invoke this plugin's subcommands.
Annotations: map[string]string{"placeholder": "true"},
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
fmt.Fprintln(ctx.Stderr, "Not implemented yet — coming in a future prompt") fmt.Fprintln(ctx.Stderr, "Not implemented yet — coming in a future prompt")
return nil return nil

4
tools/roslyn-helper/.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
bin/
obj/
publish/
*.user

View File

@ -0,0 +1,62 @@
# ctx-roslyn-helper
C# subprocess invoked by `ctx csharp` commands. Loads .NET solutions with
Roslyn and answers JSON-RPC queries over stdin/stdout.
## Protocol
One JSON object per line, UTF-8 without BOM.
**Request:** `{"id": 1, "method": "ping", "params": {}}`
**Success:** `{"id": 1, "result": {...}}`
**Error:** `{"id": 1, "error": {"code": "E_NOT_FOUND", "message": "..."}}`
### Methods
| Method | Params | Description |
|------------------|---------------------------|------------------------------------|
| `ping` | `{}` | Health check, returns version |
| `loadSolution` | `{"path": "C:\\...\\x.sln"}` | Load solution into workspace |
| `projectSummary` | `{}` | Summary of loaded solution |
| `shutdown` | `{}` | Exit cleanly (no response written) |
## Build
Requires: .NET 8+ SDK.
```sh
dotnet build src/RoslynHelper -c Release
```
## Publish
```sh
dotnet publish src/RoslynHelper -c Release -r win-x64 --self-contained false -o publish/
```
Output: `publish/ctx-roslyn-helper.exe`
## Manual test
```sh
cd publish
./ctx-roslyn-helper.exe
```
Then type line by line:
```
{"id": 1, "method": "ping", "params": {}}
{"id": 2, "method": "loadSolution", "params": {"path": "C:\\path\\to\\My.sln"}}
{"id": 3, "method": "projectSummary", "params": {}}
{"id": 99, "method": "shutdown", "params": {}}
```
## Notes
- Does NOT need to be self-contained. Requires .NET 8+ runtime on the target machine.
Users of `ctx csharp` are .NET developers — the runtime is already there.
- MSBuild warnings logged to stderr are informational (missing SDK targets, etc.).
Only `WorkspaceFailed` events with `Failure` kind indicate real problems.
- The helper is spawned once per `ctx` invocation and kept alive for all queries
in that session. ctx Go manages the subprocess lifecycle.

View File

@ -0,0 +1,39 @@

Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.0.31903.59
MinimumVisualStudioVersion = 10.0.40219.1
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72D-47B6-A68D-7590B98EB39B}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RoslynHelper", "src\RoslynHelper\RoslynHelper.csproj", "{CE832F0D-696C-4830-977F-D694DDCBA532}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Debug|x64 = Debug|x64
Debug|x86 = Debug|x86
Release|Any CPU = Release|Any CPU
Release|x64 = Release|x64
Release|x86 = Release|x86
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{CE832F0D-696C-4830-977F-D694DDCBA532}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{CE832F0D-696C-4830-977F-D694DDCBA532}.Debug|Any CPU.Build.0 = Debug|Any CPU
{CE832F0D-696C-4830-977F-D694DDCBA532}.Debug|x64.ActiveCfg = Debug|Any CPU
{CE832F0D-696C-4830-977F-D694DDCBA532}.Debug|x64.Build.0 = Debug|Any CPU
{CE832F0D-696C-4830-977F-D694DDCBA532}.Debug|x86.ActiveCfg = Debug|Any CPU
{CE832F0D-696C-4830-977F-D694DDCBA532}.Debug|x86.Build.0 = Debug|Any CPU
{CE832F0D-696C-4830-977F-D694DDCBA532}.Release|Any CPU.ActiveCfg = Release|Any CPU
{CE832F0D-696C-4830-977F-D694DDCBA532}.Release|Any CPU.Build.0 = Release|Any CPU
{CE832F0D-696C-4830-977F-D694DDCBA532}.Release|x64.ActiveCfg = Release|Any CPU
{CE832F0D-696C-4830-977F-D694DDCBA532}.Release|x64.Build.0 = Release|Any CPU
{CE832F0D-696C-4830-977F-D694DDCBA532}.Release|x86.ActiveCfg = Release|Any CPU
{CE832F0D-696C-4830-977F-D694DDCBA532}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{CE832F0D-696C-4830-977F-D694DDCBA532} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
EndGlobalSection
EndGlobal

View File

@ -0,0 +1,96 @@
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Json.Serialization;
using RoslynHelper.JsonRpc.Handlers;
using RoslynHelper.Workspace;
namespace RoslynHelper.JsonRpc;
/// <summary>
/// Reads newline-delimited JSON requests from stdin, dispatches to registered
/// handlers, and writes responses to stdout. One request per line, one response
/// per line, UTF-8 without BOM.
/// </summary>
public sealed class Dispatcher
{
private static readonly JsonSerializerOptions JsonOpts = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = false,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
// Allow literal single quotes and em dashes; Go's json.Unmarshal handles them fine.
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
};
private readonly Dictionary<string, IHandler> _handlers;
public Dispatcher(WorkspaceManager workspace)
{
_handlers = new Dictionary<string, IHandler>(StringComparer.OrdinalIgnoreCase)
{
["ping"] = new PingHandler(),
["loadSolution"] = new LoadSolutionHandler(workspace),
["projectSummary"] = new ProjectSummaryHandler(workspace),
["outline"] = new OutlineHandler(),
};
}
public async Task RunAsync(TextReader stdin, TextWriter stdout)
{
string? line;
while ((line = await stdin.ReadLineAsync()) is not null)
{
if (string.IsNullOrWhiteSpace(line)) continue;
var response = await DispatchAsync(line);
if (response is null) return; // shutdown
await stdout.WriteLineAsync(response);
await stdout.FlushAsync();
}
}
private async Task<string?> DispatchAsync(string line)
{
Request? req;
try
{
req = JsonSerializer.Deserialize<Request>(line, JsonOpts);
if (req is null || string.IsNullOrEmpty(req.Method))
return Err(0, "E_INVALID_REQUEST", "request must have id and method");
}
catch (JsonException ex)
{
return Err(0, "E_INVALID_REQUEST", $"invalid JSON: {ex.Message}");
}
if (req.Method.Equals("shutdown", StringComparison.OrdinalIgnoreCase))
return null; // signal caller to exit
if (!_handlers.TryGetValue(req.Method, out var handler))
return Err(req.Id, "E_UNKNOWN_METHOD", $"method '{req.Method}' not found");
try
{
var result = await handler.HandleAsync(req.Params);
return Ok(req.Id, result);
}
catch (KnownException kex)
{
return Err(req.Id, kex.Code, kex.Message);
}
catch (Exception ex)
{
return ErrWithData(req.Id, "E_INTERNAL", ex.Message, new { stackTrace = ex.StackTrace });
}
}
private string Ok(int id, object result) =>
JsonSerializer.Serialize(new { id, result }, JsonOpts);
private string Err(int id, string code, string message) =>
JsonSerializer.Serialize(new { id, error = new { code, message } }, JsonOpts);
private string ErrWithData(int id, string code, string message, object data) =>
JsonSerializer.Serialize(new { id, error = new { code, message, data } }, JsonOpts);
}

View File

@ -0,0 +1,10 @@
using System.Text.Json;
namespace RoslynHelper.JsonRpc.Handlers;
/// <summary>Contract for a JSON-RPC method handler.</summary>
public interface IHandler
{
/// <summary>Execute the handler and return the result object (serialized as the 'result' field).</summary>
Task<object> HandleAsync(JsonElement? @params);
}

View File

@ -0,0 +1,24 @@
using System.Text.Json;
using RoslynHelper.Workspace;
namespace RoslynHelper.JsonRpc.Handlers;
/// <summary>
/// Loads a .sln file into the Roslyn workspace.
/// Idempotent — if the same path is already loaded, reloads it.
/// </summary>
public sealed class LoadSolutionHandler(WorkspaceManager workspace) : IHandler
{
public async Task<object> HandleAsync(JsonElement? @params)
{
if (@params is not { } p)
throw new KnownException("E_INVALID_REQUEST", "params required for loadSolution");
if (!p.TryGetProperty("path", out var pathEl) || pathEl.ValueKind != JsonValueKind.String)
throw new KnownException("E_INVALID_REQUEST", "params.path (string) is required");
var path = pathEl.GetString()!;
var (projectCount, documentCount) = await workspace.LoadAsync(path);
return new { loaded = true, projectCount, documentCount };
}
}

View File

@ -0,0 +1,281 @@
using System.Text.Json;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using RoslynHelper.Models;
namespace RoslynHelper.JsonRpc.Handlers;
/// <summary>
/// Parses a single .cs file and returns its structural outline:
/// namespaces, types, method signatures (no bodies), properties, fields, events.
/// Does NOT require a solution to be loaded — works on a single file.
/// </summary>
public sealed class OutlineHandler : IHandler
{
public async Task<object> HandleAsync(JsonElement? @params)
{
if (@params is not { } p)
throw new KnownException("E_INVALID_PARAMS", "params required for outline");
if (!p.TryGetProperty("path", out var pathEl) || pathEl.ValueKind != JsonValueKind.String)
throw new KnownException("E_INVALID_PARAMS", "params.path (string) is required");
var path = pathEl.GetString()!;
if (!File.Exists(path))
throw new KnownException("E_NOT_FOUND", $"file not found: {path}");
var source = await File.ReadAllTextAsync(path, System.Text.Encoding.UTF8);
var tree = CSharpSyntaxTree.ParseText(source, path: path);
var root = (CompilationUnitSyntax)await tree.GetRootAsync();
bool hasSyntaxErrors = tree.GetDiagnostics()
.Any(d => d.Severity == DiagnosticSeverity.Error);
int lineCount = source.Split('\n').Length;
// Top-level usings
var usings = CollectUsings(root.Usings);
string ns = "";
var types = new List<OutlineTypeModel>();
bool hasTopLevel = false;
foreach (var member in root.Members)
{
switch (member)
{
case NamespaceDeclarationSyntax blockNs:
ns = blockNs.Name.ToString();
usings.AddRange(CollectUsings(blockNs.Usings));
foreach (var m in blockNs.Members)
CollectType(m, types);
break;
case FileScopedNamespaceDeclarationSyntax fileScopedNs:
ns = fileScopedNs.Name.ToString();
usings.AddRange(CollectUsings(fileScopedNs.Usings));
foreach (var m in fileScopedNs.Members)
CollectType(m, types);
break;
case GlobalStatementSyntax:
hasTopLevel = true;
break;
default:
CollectType(member, types);
break;
}
}
// Synthetic Program type for top-level statement files
if (hasTopLevel)
{
types.Insert(0, new OutlineTypeModel(
"class", "Program (top-level program)", [], [],
[new OutlineMemberModel("method", "static void Main(string[] args)", ["static"], 1, false)],
[]));
}
return new OutlineResult(path, ns, lineCount, usings.Distinct().ToList(), types, hasSyntaxErrors);
}
// ─── Usings ─────────────────────────────────────────────────────────────
private static List<string> CollectUsings(SyntaxList<UsingDirectiveSyntax> usings) =>
usings
.Where(u => u.Alias is null)
.Select(u => u.Name?.ToString() ?? "")
.Where(s => !string.IsNullOrEmpty(s))
.ToList();
// ─── Type dispatch ───────────────────────────────────────────────────────
private static void CollectType(MemberDeclarationSyntax member, List<OutlineTypeModel> target)
{
switch (member)
{
case ClassDeclarationSyntax cls:
target.Add(ExtractTypeDecl("class", cls.Identifier.Text,
cls.TypeParameterList, null, cls.Modifiers, cls.BaseList, cls.Members));
break;
case InterfaceDeclarationSyntax iface:
target.Add(ExtractTypeDecl("interface", iface.Identifier.Text,
iface.TypeParameterList, null, iface.Modifiers, iface.BaseList, iface.Members));
break;
case StructDeclarationSyntax str:
target.Add(ExtractTypeDecl("struct", str.Identifier.Text,
str.TypeParameterList, null, str.Modifiers, str.BaseList, str.Members));
break;
case RecordDeclarationSyntax rec:
{
var recKind = rec.ClassOrStructKeyword.IsKind(SyntaxKind.StructKeyword)
? "record struct" : "record";
target.Add(ExtractTypeDecl(recKind, rec.Identifier.Text,
rec.TypeParameterList, rec.ParameterList, rec.Modifiers, rec.BaseList, rec.Members));
break;
}
case EnumDeclarationSyntax en:
target.Add(ExtractEnum(en));
break;
}
}
// ─── Type extraction ─────────────────────────────────────────────────────
private static OutlineTypeModel ExtractTypeDecl(
string kind,
string name,
TypeParameterListSyntax? typeParams,
ParameterListSyntax? recordParams,
SyntaxTokenList modifiers,
BaseListSyntax? baseList,
SyntaxList<MemberDeclarationSyntax> members)
{
var mods = modifiers.Select(m => m.Text).ToList();
var baseTypes = baseList?.Types.Select(t => t.Type.ToString()).ToList() ?? [];
var fullName = name
+ (typeParams?.ToString() ?? "")
+ (recordParams?.ToString() ?? "");
var memberModels = new List<OutlineMemberModel>();
var nested = new List<OutlineTypeModel>();
foreach (var member in members)
{
switch (member)
{
case MethodDeclarationSyntax m:
memberModels.Add(ExtractMethod(m));
break;
case ConstructorDeclarationSyntax c:
memberModels.Add(ExtractConstructor(c));
break;
case PropertyDeclarationSyntax prop:
memberModels.Add(ExtractProperty(prop));
break;
case FieldDeclarationSyntax f:
memberModels.AddRange(ExtractFields(f));
break;
case EventDeclarationSyntax e:
memberModels.Add(ExtractEventDecl(e));
break;
case EventFieldDeclarationSyntax ef:
memberModels.AddRange(ExtractEventFields(ef));
break;
case ClassDeclarationSyntax _:
case InterfaceDeclarationSyntax _:
case StructDeclarationSyntax _:
case RecordDeclarationSyntax _:
case EnumDeclarationSyntax _:
CollectType(member, nested);
break;
}
}
return new OutlineTypeModel(kind, fullName, mods, baseTypes, memberModels, nested);
}
private static OutlineTypeModel ExtractEnum(EnumDeclarationSyntax en)
{
var mods = en.Modifiers.Select(m => m.Text).ToList();
var members = en.Members
.Select(m => new OutlineMemberModel("enumValue", m.Identifier.Text, [], GetLine(m), false))
.ToList();
return new OutlineTypeModel("enum", en.Identifier.Text, mods, [], members, []);
}
// ─── Member extraction ───────────────────────────────────────────────────
private static OutlineMemberModel ExtractMethod(MethodDeclarationSyntax m)
{
var typeParams = m.TypeParameterList?.ToString() ?? "";
var sig = $"{m.ReturnType} {m.Identifier.Text}{typeParams}{m.ParameterList}".Trim();
var mods = m.Modifiers.Select(x => x.Text).ToList();
return new OutlineMemberModel("method", sig, mods, GetLine(m), HasObsolete(m.AttributeLists));
}
private static OutlineMemberModel ExtractConstructor(ConstructorDeclarationSyntax c)
{
var sig = $"{c.Identifier.Text}{c.ParameterList}".Trim();
var mods = c.Modifiers.Select(x => x.Text).ToList();
return new OutlineMemberModel("constructor", sig, mods, GetLine(c), HasObsolete(c.AttributeLists));
}
private static OutlineMemberModel ExtractProperty(PropertyDeclarationSyntax p)
{
string accessors;
if (p.AccessorList is { } al)
{
var parts = al.Accessors.Select(a =>
{
var accMods = a.Modifiers.Any() ? a.Modifiers.ToString() + " " : "";
return accMods + a.Keyword.Text;
});
accessors = "{ " + string.Join("; ", parts) + "; }";
}
else
{
accessors = "=> ..."; // expression-bodied
}
var sig = $"{p.Type} {p.Identifier.Text} {accessors}".Trim();
var mods = p.Modifiers.Select(x => x.Text).ToList();
return new OutlineMemberModel("property", sig, mods, GetLine(p), HasObsolete(p.AttributeLists));
}
private static IEnumerable<OutlineMemberModel> ExtractFields(FieldDeclarationSyntax f)
{
var mods = f.Modifiers.Select(x => x.Text).ToList();
var typeName = f.Declaration.Type.ToString();
var isConst = mods.Contains("const");
var obs = HasObsolete(f.AttributeLists);
var line = GetLine(f);
foreach (var v in f.Declaration.Variables)
{
var sig = isConst && v.Initializer is { } init
? $"{typeName} {v.Identifier.Text} = {init.Value}"
: $"{typeName} {v.Identifier.Text}";
yield return new OutlineMemberModel("field", sig.Trim(), mods, line, obs);
}
}
private static OutlineMemberModel ExtractEventDecl(EventDeclarationSyntax e)
{
var sig = $"event {e.Type} {e.Identifier.Text}".Trim();
var mods = e.Modifiers.Select(x => x.Text).ToList();
return new OutlineMemberModel("event", sig, mods, GetLine(e), HasObsolete(e.AttributeLists));
}
private static IEnumerable<OutlineMemberModel> ExtractEventFields(EventFieldDeclarationSyntax ef)
{
var mods = ef.Modifiers.Select(x => x.Text).ToList();
var typeName = ef.Declaration.Type.ToString();
var obs = HasObsolete(ef.AttributeLists);
var line = GetLine(ef);
foreach (var v in ef.Declaration.Variables)
{
yield return new OutlineMemberModel(
"event", $"event {typeName} {v.Identifier.Text}".Trim(), mods, line, obs);
}
}
// ─── Helpers ─────────────────────────────────────────────────────────────
// Returns true if obsolete, null otherwise (null omitted by WhenWritingNull in dispatcher)
private static bool? HasObsolete(SyntaxList<AttributeListSyntax> attrs)
{
var found = attrs.SelectMany(al => al.Attributes)
.Any(a => a.Name.ToString() is "Obsolete" or "ObsoleteAttribute");
return found ? true : null;
}
private static int GetLine(SyntaxNode node) =>
node.GetLocation().GetLineSpan().StartLinePosition.Line + 1;
}

View File

@ -0,0 +1,10 @@
using System.Text.Json;
namespace RoslynHelper.JsonRpc.Handlers;
/// <summary>Health-check handler. Returns version string.</summary>
public sealed class PingHandler : IHandler
{
public Task<object> HandleAsync(JsonElement? @params) =>
Task.FromResult<object>(new { pong = true, version = "0.1.0" });
}

View File

@ -0,0 +1,47 @@
using System.Text.Json;
using RoslynHelper.Models;
using RoslynHelper.Workspace;
namespace RoslynHelper.JsonRpc.Handlers;
/// <summary>
/// Returns a compact summary of the loaded solution: projects, target frameworks,
/// package references, and project-to-project dependencies.
/// Parses .csproj XML directly — no Roslyn workspace required.
/// </summary>
public sealed class ProjectSummaryHandler(WorkspaceManager workspace) : IHandler
{
public Task<object> HandleAsync(JsonElement? @params)
{
var solution = workspace.GetCurrentSolution();
var solutionPath = workspace.SolutionPath!;
var solutionName = Path.GetFileNameWithoutExtension(solutionPath);
var solutionDir = Path.GetDirectoryName(solutionPath) ?? string.Empty;
// Document counts are tracked per-project in the WorkspaceManager load.
// Re-compute here from file system for simplicity.
var projects = solution.Projects
.OrderBy(p => p.Name)
.Select(p =>
{
var docCount = CountDocuments(p.ProjectPath);
return ProjectSummaryBuilder.Build(p, solutionDir, docCount);
})
.ToList();
return Task.FromResult<object>(new SolutionSummary(solutionPath, solutionName, projects));
}
private static int CountDocuments(string projPath)
{
var dir = Path.GetDirectoryName(projPath);
if (dir is null || !Directory.Exists(dir)) return 0;
try
{
return Directory.EnumerateFiles(dir, "*.cs", SearchOption.AllDirectories).Count()
+ Directory.EnumerateFiles(dir, "*.fs", SearchOption.AllDirectories).Count()
+ Directory.EnumerateFiles(dir, "*.vb", SearchOption.AllDirectories).Count();
}
catch { return 0; }
}
}

View File

@ -0,0 +1,11 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace RoslynHelper.JsonRpc;
/// <summary>Incoming JSON-RPC request from ctx (Go).</summary>
public sealed record Request(
[property: JsonPropertyName("id")] int Id,
[property: JsonPropertyName("method")] string Method,
[property: JsonPropertyName("params")] JsonElement? Params
);

View File

@ -0,0 +1,10 @@
using System.Text.Json.Serialization;
namespace RoslynHelper.JsonRpc;
/// <summary>Error detail embedded in a JSON-RPC error response.</summary>
public sealed record ErrorDetail(
[property: JsonPropertyName("code")] string Code,
[property: JsonPropertyName("message")] string Message,
[property: JsonPropertyName("data")] object? Data = null
);

View File

@ -0,0 +1,10 @@
namespace RoslynHelper;
/// <summary>
/// An expected error with a standardized error code.
/// Throw from handlers to produce structured JSON-RPC error responses.
/// </summary>
public sealed class KnownException(string code, string message) : Exception(message)
{
public string Code { get; } = code;
}

View File

@ -0,0 +1,33 @@
using System.Text.Json.Serialization;
namespace RoslynHelper.Models;
public sealed record OutlineResult(
[property: JsonPropertyName("path")] string Path,
[property: JsonPropertyName("namespace")] string Namespace,
[property: JsonPropertyName("lineCount")] int LineCount,
[property: JsonPropertyName("usings")] List<string> Usings,
[property: JsonPropertyName("types")] List<OutlineTypeModel> Types,
[property: JsonPropertyName("hasSyntaxErrors")] bool HasSyntaxErrors
);
public sealed record OutlineTypeModel(
[property: JsonPropertyName("kind")] string Kind,
[property: JsonPropertyName("name")] string Name,
[property: JsonPropertyName("modifiers")] List<string> Modifiers,
[property: JsonPropertyName("baseTypes")] List<string> BaseTypes,
[property: JsonPropertyName("members")] List<OutlineMemberModel> Members,
[property: JsonPropertyName("nested")] List<OutlineTypeModel> Nested
);
/// <remarks>
/// <c>IsObsolete</c> is nullable so that <c>null</c> (not obsolete) is omitted
/// from JSON by the dispatcher's <c>WhenWritingNull</c> policy.
/// </remarks>
public sealed record OutlineMemberModel(
[property: JsonPropertyName("kind")] string Kind,
[property: JsonPropertyName("signature")] string Signature,
[property: JsonPropertyName("modifiers")] List<string> Modifiers,
[property: JsonPropertyName("line")] int Line,
[property: JsonPropertyName("isObsolete")] bool? IsObsolete = null
);

View File

@ -0,0 +1,102 @@
using System.Text.Json.Serialization;
using System.Xml.Linq;
using RoslynHelper.Workspace;
namespace RoslynHelper.Models;
public sealed record PackageRef(
[property: JsonPropertyName("name")] string Name,
[property: JsonPropertyName("version")] string Version
);
public sealed record ProjectSummary(
[property: JsonPropertyName("name")] string Name,
[property: JsonPropertyName("path")] string Path,
[property: JsonPropertyName("type")] string Type,
[property: JsonPropertyName("targetFrameworks")] string[] TargetFrameworks,
[property: JsonPropertyName("outputType")] string OutputType,
[property: JsonPropertyName("rootNamespace")] string RootNamespace,
[property: JsonPropertyName("documentCount")] int DocumentCount,
[property: JsonPropertyName("projectReferences")] List<string> ProjectReferences,
[property: JsonPropertyName("packageReferences")] List<PackageRef> PackageReferences
);
public sealed record SolutionSummary(
[property: JsonPropertyName("solutionPath")] string SolutionPath,
[property: JsonPropertyName("solutionName")] string SolutionName,
[property: JsonPropertyName("projects")] List<ProjectSummary> Projects
);
/// <summary>
/// Builds a <see cref="ProjectSummary"/> from a <see cref="ProjectEntry"/>
/// by parsing the .csproj XML for MSBuild properties.
/// </summary>
internal static class ProjectSummaryBuilder
{
public static ProjectSummary Build(ProjectEntry entry, string solutionDir, int documentCount)
{
var projPath = entry.ProjectPath;
string[] targetFrameworks = [];
string outputType = "Library";
string rootNamespace = entry.Name;
List<PackageRef> packageRefs = [];
List<string> projRefs = [];
if (File.Exists(projPath))
{
try
{
var doc = XDocument.Load(projPath);
var tf = doc.Descendants("TargetFramework").FirstOrDefault()?.Value?.Trim();
var tfs = doc.Descendants("TargetFrameworks").FirstOrDefault()?.Value?.Trim();
targetFrameworks = tfs is not null
? tfs.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
: tf is not null ? [tf] : [];
outputType = doc.Descendants("OutputType").FirstOrDefault()?.Value?.Trim() ?? "Library";
rootNamespace = doc.Descendants("RootNamespace").FirstOrDefault()?.Value?.Trim() ?? entry.Name;
packageRefs = doc.Descendants("PackageReference")
.Select(el => new PackageRef(
el.Attribute("Include")?.Value ?? string.Empty,
el.Attribute("Version")?.Value ?? el.Element("Version")?.Value ?? string.Empty))
.Where(pr => !string.IsNullOrEmpty(pr.Name))
.OrderBy(pr => pr.Name)
.ToList();
projRefs = doc.Descendants("ProjectReference")
.Select(el =>
{
var include = el.Attribute("Include")?.Value ?? string.Empty;
return Path.GetFileNameWithoutExtension(include);
})
.Where(n => !string.IsNullOrEmpty(n))
.OrderBy(n => n)
.ToList();
}
catch (Exception ex)
{
Console.Error.WriteLine($"[summary] warn: could not parse {projPath}: {ex.Message}");
}
}
var type = outputType.Equals("Exe", StringComparison.OrdinalIgnoreCase) ||
outputType.Equals("WinExe", StringComparison.OrdinalIgnoreCase)
? "exe" : "lib";
return new ProjectSummary(
entry.Name,
entry.RelativePath,
type,
targetFrameworks,
outputType,
rootNamespace,
documentCount,
projRefs,
packageRefs
);
}
}

View File

@ -0,0 +1,20 @@
using RoslynHelper.JsonRpc;
using RoslynHelper.Workspace;
namespace RoslynHelper;
public static class Program
{
public static async Task<int> Main(string[] args)
{
// UTF-8 without BOM on both ends of the pipe.
Console.InputEncoding = new System.Text.UTF8Encoding(encoderShouldEmitUTF8Identifier: false);
Console.OutputEncoding = new System.Text.UTF8Encoding(encoderShouldEmitUTF8Identifier: false);
using var workspace = new WorkspaceManager();
var dispatcher = new Dispatcher(workspace);
await dispatcher.RunAsync(Console.In, Console.Out);
return 0;
}
}

View File

@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<LangVersion>latest</LangVersion>
<AssemblyName>ctx-roslyn-helper</AssemblyName>
<RootNamespace>RoslynHelper</RootNamespace>
</PropertyGroup>
<ItemGroup>
<!-- Pure C# syntax parser — no MSBuild, no SDK coupling.
Used by OutlineHandler for CSharpSyntaxTree.ParseText + SyntaxWalker.
Unlike Workspaces.MSBuild (removed in Prompt 3), this package
has no runtime dependency on the installed SDK. -->
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.13.0" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,119 @@
using System.Text.RegularExpressions;
using System.Xml.Linq;
namespace RoslynHelper.Workspace;
/// <summary>
/// Parses .sln and .csproj files directly without a Roslyn/MSBuild workspace.
/// This avoids tight version coupling between MSBuild NuGet packages and the
/// installed SDK. For MVP commands (projectSummary), structural parsing is sufficient.
/// Roslyn semantic APIs will be integrated in a later phase.
/// </summary>
public sealed class WorkspaceManager : IDisposable
{
public void Dispose() { } // reserved for future Roslyn workspace disposal
private SolutionData? _solution;
private string? _solutionPath;
public string? SolutionPath => _solutionPath;
public Task<(int projectCount, int documentCount)> LoadAsync(string path)
{
if (!File.Exists(path))
throw new KnownException("E_NOT_FOUND", $"solution file not found: {path}");
SolutionData sln;
try
{
sln = SolutionParser.Parse(path);
}
catch (Exception ex)
{
throw new KnownException("E_LOAD_FAILED", $"failed to load solution: {ex.Message}");
}
_solution = sln;
_solutionPath = path;
var documentCount = sln.Projects.Sum(p => CountDocuments(p.ProjectPath));
return Task.FromResult((sln.Projects.Count, documentCount));
}
public SolutionData GetCurrentSolution()
{
if (_solution is null)
throw new KnownException("E_NOT_FOUND", "no solution loaded — call loadSolution first");
return _solution;
}
/// <summary>Counts .cs/.fs/.vb source files in the project directory.</summary>
private static int CountDocuments(string projPath)
{
var dir = Path.GetDirectoryName(projPath);
if (dir is null || !Directory.Exists(dir)) return 0;
try
{
return Directory.EnumerateFiles(dir, "*.cs", SearchOption.AllDirectories).Count()
+ Directory.EnumerateFiles(dir, "*.fs", SearchOption.AllDirectories).Count()
+ Directory.EnumerateFiles(dir, "*.vb", SearchOption.AllDirectories).Count();
}
catch { return 0; }
}
}
/// <summary>Lightweight data model for a loaded solution.</summary>
public sealed class SolutionData(string solutionPath, List<ProjectEntry> projects)
{
public string SolutionPath { get; } = solutionPath;
public List<ProjectEntry> Projects { get; } = projects;
}
/// <summary>One project entry from the .sln file.</summary>
public sealed class ProjectEntry(string name, string relativePath, string absolutePath)
{
public string Name { get; } = name;
public string RelativePath { get; } = relativePath;
public string ProjectPath { get; } = absolutePath;
}
/// <summary>
/// Parses the classic Visual Studio .sln format to extract project entries.
/// Handles both SDK-style and legacy projects.
/// </summary>
internal static class SolutionParser
{
// Matches: Project("{type-guid}") = "Name", "path\to\project.csproj", "{project-guid}"
private static readonly Regex ProjectLine = new(
@"Project\(""\{[^}]+\}""\)\s*=\s*""([^""]+)""\s*,\s*""([^""]+)""\s*,",
RegexOptions.Compiled);
private static readonly HashSet<string> ProjectExtensions = new(StringComparer.OrdinalIgnoreCase)
{
".csproj", ".fsproj", ".vbproj", ".pyproj", ".vcxproj"
};
public static SolutionData Parse(string slnPath)
{
var slnDir = Path.GetDirectoryName(slnPath) ?? string.Empty;
var content = File.ReadAllText(slnPath, System.Text.Encoding.UTF8);
var projects = new List<ProjectEntry>();
foreach (Match m in ProjectLine.Matches(content))
{
var name = m.Groups[1].Value;
var relPath = m.Groups[2].Value.Replace('/', Path.DirectorySeparatorChar);
var ext = Path.GetExtension(relPath);
// Skip solution folders (no file extension) and non-code projects.
if (string.IsNullOrEmpty(ext) || !ProjectExtensions.Contains(ext))
continue;
var absPath = Path.GetFullPath(Path.Combine(slnDir, relPath));
if (!File.Exists(absPath)) continue; // skip phantom entries
projects.Add(new ProjectEntry(name, relPath.Replace('\\', '/'), absPath));
}
return new SolutionData(slnPath, projects);
}
}

View File

@ -0,0 +1,3 @@
{"id": 1, "method": "loadSolution", "params": {"path": "C:\\gocode\\ctx\\tools\\roslyn-helper\\RoslynHelper.sln"}}
{"id": 2, "method": "projectSummary", "params": {}}
{"id": 3, "method": "shutdown", "params": {}}