-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathProgram.cs
More file actions
301 lines (252 loc) · 9.77 KB
/
Copy pathProgram.cs
File metadata and controls
301 lines (252 loc) · 9.77 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
using System.Text.Json;
using System.Text.Json.Nodes;
using RoslynLean;
if (args.Length >= 1 && args[0] == "install-hooks")
{
return InstallHooks(args);
}
if (args.Length < 2 || args[0] != "focus")
{
PrintUsage();
return 1;
}
var csharpFilePath = args.Skip(1)
.FirstOrDefault(a => a.StartsWith("--csharpfile="))
?.Substring("--csharpfile=".Length);
var methodName = args.Skip(1)
.FirstOrDefault(a => a.StartsWith("--method="))
?.Substring("--method=".Length);
var showStats = args.Contains("--stats");
var writeReport = args.Contains("--report");
var aliasMode = args.Contains("--alias");
var minifyFlag = args.Contains("--minify");
var depthArg = args.Skip(1)
.FirstOrDefault(a => a.StartsWith("--depth="))
?.Substring("--depth=".Length);
var depth = int.TryParse(depthArg, out var d) ? Math.Max(0, d) : 0;
// Positional path is whatever non-flag arg sits after "focus"
var positionalPath = args.Skip(1).FirstOrDefault(a => !a.StartsWith("--"));
string sourcePath;
bool minifyMode;
if (!string.IsNullOrEmpty(csharpFilePath))
{
sourcePath = csharpFilePath;
minifyMode = true;
}
else if (!string.IsNullOrEmpty(methodName) && !string.IsNullOrEmpty(positionalPath))
{
sourcePath = positionalPath;
minifyMode = false;
}
else
{
PrintUsage();
return 1;
}
try
{
var emitter = new FocusedEmitter(sourcePath);
var result = minifyMode
? (aliasMode ? emitter.EmitAliased() : emitter.EmitMinified())
: emitter.Emit(methodName!, depth);
if (!result.Found)
{
Console.Error.WriteLine(result.Output);
return 2;
}
// --minify post-processor for --method mode (no-op for --csharpfile, already minified)
if (minifyFlag && !minifyMode)
{
var minified = FocusedEmitter.MinifyText(result.Output);
result = result with { Output = minified, FocusedChars = minified.Length };
}
Console.Write(result.Notes);
Console.WriteLine();
Console.Write(result.Output);
if (showStats)
{
Console.Error.WriteLine();
Console.Error.WriteLine($"Original: {result.OriginalChars,6} chars (~{result.OriginalTokensEstimate} tokens)");
Console.Error.WriteLine($"Focused: {result.FocusedChars,6} chars (~{result.FocusedTokensEstimate} tokens)");
Console.Error.WriteLine($"Reduction: {result.ReductionPercent:F1}%");
}
if (writeReport)
{
var toolName = minifyMode
? (aliasMode ? "Alias" : "Minify")
: "Focused Emitter";
var notes = minifyMode
? Path.GetFileName(sourcePath)
: $"{Path.GetFileName(sourcePath)} / {methodName} (depth={depth})";
TokenSaver.ReportWriter.Append(
toolName: toolName,
language: "C#",
tokensWithoutTool: result.OriginalTokensEstimate,
tokensWithTool: result.FocusedTokensEstimate,
notes: notes,
source: "cli");
Console.Error.WriteLine($"Appended report entry to {TokenSaver.ReportWriter.DefaultPath}");
}
return 0;
}
catch (Exception ex)
{
Console.Error.WriteLine($"Error: {ex.Message}");
return 3;
}
static int InstallHooks(string[] args)
{
var targetArg = args.Skip(1)
.FirstOrDefault(a => a.StartsWith("--target="))
?.Substring("--target=".Length);
var target = string.IsNullOrEmpty(targetArg)
? Directory.GetCurrentDirectory()
: Path.GetFullPath(targetArg);
if (!Directory.Exists(target))
{
Console.Error.WriteLine($"Target directory does not exist: {target}");
return 1;
}
var claudeDir = Path.Combine(target, ".claude");
var hooksDir = Path.Combine(claudeDir, "hooks");
Directory.CreateDirectory(hooksDir);
const string remindHookScript = """
$ErrorActionPreference = 'SilentlyContinue'
try {
$payload = [Console]::In.ReadToEnd() | ConvertFrom-Json
} catch {
exit 0
}
if ($payload.tool_name -ne 'Read') { exit 0 }
$path = $payload.tool_input.file_path
if (-not $path) { exit 0 }
if ($path -notmatch '\.(cs|razor\.cs)$') { exit 0 }
if (Test-Path -LiteralPath $path) {
$lineCount = (Get-Content -LiteralPath $path -ErrorAction SilentlyContinue | Measure-Object -Line).Lines
if ($lineCount -lt 50) { exit 0 }
}
$reminder = @"
You are about to Read a C# file ($path) with the built-in Read tool.
Prefer the roslyn-lean MCP server (registered for this project):
- minify_file : lossless ~20-50% reduction for whole-file reads
- focus_method : when you need a specific method (use depth=1)
- outline_c_sharp_file: signatures only — best for "what's in this file?"
ONLY use Read directly when you need exact on-disk text for an Edit call,
or when the user explicitly asked for the raw file. Otherwise, cancel
this Read and call the appropriate MCP tool instead.
"@
$out = @{
hookSpecificOutput = @{
hookEventName = 'PreToolUse'
additionalContext = $reminder
}
} | ConvertTo-Json -Depth 5 -Compress
Write-Output $out
exit 0
""";
var scriptPath = Path.Combine(hooksDir, "remind-csharp-mcp.ps1");
File.WriteAllText(scriptPath, remindHookScript);
Console.Error.WriteLine($"Wrote {scriptPath}");
var settingsPath = Path.Combine(claudeDir, "settings.json");
JsonObject root;
if (File.Exists(settingsPath))
{
try
{
root = JsonNode.Parse(File.ReadAllText(settingsPath)) as JsonObject ?? new JsonObject();
}
catch (JsonException)
{
File.Move(settingsPath, settingsPath + ".bak", overwrite: true);
Console.Error.WriteLine($"Existing settings.json was unparseable; backed up to {settingsPath}.bak");
root = new JsonObject();
}
}
else
{
root = new JsonObject();
}
if (root["hooks"] is not JsonObject hooks)
{
hooks = new JsonObject();
root["hooks"] = hooks;
}
if (hooks["PreToolUse"] is not JsonArray preToolUse)
{
preToolUse = new JsonArray();
hooks["PreToolUse"] = preToolUse;
}
const string command =
"powershell -NoProfile -ExecutionPolicy Bypass -File .claude/hooks/remind-csharp-mcp.ps1";
var alreadyInstalled = preToolUse
.OfType<JsonObject>()
.Any(entry =>
entry["matcher"]?.GetValue<string>() == "Read" &&
entry["hooks"] is JsonArray inner &&
inner.OfType<JsonObject>().Any(h => h["command"]?.GetValue<string>() == command));
if (alreadyInstalled)
{
Console.Error.WriteLine($"Hook already present in {settingsPath} — no change");
}
else
{
preToolUse.Add(new JsonObject
{
["matcher"] = "Read",
["hooks"] = new JsonArray
{
new JsonObject
{
["type"] = "command",
["command"] = command,
},
},
});
File.WriteAllText(
settingsPath,
root.ToJsonString(new JsonSerializerOptions { WriteIndented = true }));
Console.Error.WriteLine($"Updated {settingsPath}");
}
Console.Error.WriteLine();
Console.Error.WriteLine("Done. Restart Claude Code in this directory for the hook to take effect.");
return 0;
}
static void PrintUsage() => Console.Error.WriteLine(
"""
roslyn-lean — emit a token-reduced view of a C# file for LLM consumption.
USAGE:
roslyn-lean focus <path-to-file.cs> --method=<MethodName> [--stats]
roslyn-lean focus --csharpfile=<path-to-file.cs> [--stats]
roslyn-lean install-hooks [--target=<dir>]
install-hooks Writes a Claude Code PreToolUse hook into <dir>/.claude/
(defaults to the current directory). The hook reminds the
AI to use the roslyn-lean MCP tools instead of the raw
Read tool when opening .cs files >= 50 lines. Idempotent
and merges with an existing settings.json. Windows /
PowerShell only.
--method=<Name> Focused-method mode: emits the named method with full body,
every other member of its type reduced to a signature.
Best when you know which method the AI should reason about.
--csharpfile=<P> Lossless minify mode: strips comments, XML docs, and extra
whitespace from the whole file. Logic is preserved verbatim
(Roslyn parses and re-emits the syntax tree).
Best when the AI needs the full file but you want fewer tokens.
--minify (with --method) Strip comments and collapse whitespace
from the focused output. Lossless, same transform as
--csharpfile uses by default. No-op with --csharpfile.
--depth=<N> (with --method) Also include the FULL BODIES of private
helper methods called from the focus method, up to N
transitive levels. Default 0 (signatures only).
Use 1 for "translate this method" / refactor tasks where
the AI needs to see what helpers actually do, not guess.
--alias (with --csharpfile) Also rename PRIVATE methods/properties/
fields/events to short codes (M1, P1, F1, E1...). A symbol
ledger is prepended so the LLM can map back. Public API is
left alone — we can't see callers from a single-file view.
--stats Print before/after token estimate to stderr.
--report Append this run's before/after stats as a JSON entry to
%USERPROFILE%\.tokensaver\report.json. The TokenSaverViewer
Blazor app reads that file.
OUTPUT:
The transformed source goes to stdout. Stats (if --stats) go to stderr.
""");