Skip to content

Commit 32366e8

Browse files
KenM76claude
authored andcommitted
v0.9.0: run-script as a clean Unix filter — ScriptArgs, Stdin, stderr separation
Closes TWO FRs that compose into one story: - FR_runscript_script_args.md (argv channel via ScriptArgs global) - FR_runscript_stdin_and_stderr_separation.md (Stdin global + stop redirecting Console.Error to the output writer) The Args FR had been misfiled to Complete/ at some point without code actually shipping — verified by repo-wide grep for ScriptArgs returning zero matches. v0.9.0 ships both together because they compose: a .csx becomes a proper stdin->stdout(data) + stderr(diag) filter with argv in and exit code out (the exit-code FR shipped in v0.8.2). The SWBomExcluded ScripTree provider can now retire its PowerShell wrapper shim. Architecture: - New IScriptContext interface in ComBridge.Core with ScriptArgs (string[]) and Stdin (string) properties. - Every shipped plugin's globals class (SwGlobals, ExcelGlobals, WdGlobals, PptGlobals, OlGlobals + 4 Mac variants) implements IScriptContext. - RunScriptCommand populates both fields after CreateGlobals but before invoking the host. - ScriptArgs = args.Skip(1).ToArray() — everything between the script path and the trailing output-file positional. - Stdin = ReadStdinWithTimeoutAsync(250ms) via the underlying Console.OpenStandardInput() stream + CancellationToken, extended per chunk so slow producers aren't truncated. - ScriptHost.RunAsync no longer calls Console.SetError — script stderr flows to the process's real stderr. Hang trap caught during smoke-test: if (Console.IsInputRedirected) ReadToEndAsync() hangs forever when stdin is inherited-but-empty (combridge invoked from bash subprocesses, Task Scheduler, CI runners). IsInputRedirected returns true for "non-terminal" — NOT "has data available." Test 1 (no pipe) sat at ReadToEndAsync >30s before being killed. Fix: cancellation-token timeout pattern. Captured as lesson_20260615_console_isinputredirected_inherited_handle_hang.md in personal_rag/claude_code/. Verified end-to-end against running SW 2026: Test 1 (no pipe, args only) → ScriptArgs.Length=5, exit 5, no hang Test 2 (real pipe) → 66 bytes received, exit 0 Test 3 (stderr split) → STDOUT to <out>, STDERR to real stderr Test 4 (full filter) → clean JSON on stdout, diag on stderr, args + stdin parsed, exit 0 Mirrored to D:\Dev\ScripTree\lib\combridge\ and R:\ScripTree\lib\combridge\ per the CLAUDE.md deployment mandate. Both FRs in Complete/ with implementation log stamps. The Args FR's "misfiled" history captured in its own log for the next reader. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 6d5d6fa commit 32366e8

13 files changed

Lines changed: 393 additions & 14 deletions

File tree

CHANGELOG.md

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,181 @@ All notable changes to this project will be documented in this file.
44
Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/);
55
versions follow [SemVer](https://semver.org/spec/v2.0.0.html).
66

7+
## [0.9.0]`run-script` becomes a clean Unix filter: `ScriptArgs` in, `Stdin` in, stderr separate
8+
9+
Closes TWO FRs that compose into one story:
10+
11+
- `FR_runscript_script_args.md` — argv channel via `ScriptArgs` global
12+
- `FR_runscript_stdin_and_stderr_separation.md``Stdin` global +
13+
stop redirecting `Console.Error` to the output writer
14+
15+
Both FRs had been independently filed but never implemented (the
16+
`Args` FR was misfiled to `Complete\` at some point without code
17+
shipping — verified by repo-wide grep). v0.9.0 ships both together
18+
because they compose: a `.csx` becomes a proper Unix-style
19+
`stdin → stdout(data) + stderr(diag)` filter with argv in and exit
20+
code out (exit-code FR shipped in v0.8.2). The SWBomExcluded
21+
ScripTree provider can now retire its PowerShell wrapper shim and
22+
become plain `combridge solidworks run-script provider.csx -`.
23+
24+
### Added — `IScriptContext` interface
25+
26+
New interface in `ComBridge.Core`:
27+
28+
```csharp
29+
public interface IScriptContext {
30+
string[] ScriptArgs { get; set; }
31+
string Stdin { get; set; }
32+
}
33+
```
34+
35+
Each plugin's globals class (`SwGlobals`, `ExcelGlobals`, `WdGlobals`,
36+
`PptGlobals`, `OlGlobals`, plus the four Mac variants) now implements
37+
this interface. The host (`RunScriptCommand`) casts and sets the
38+
fields after `CreateGlobals`. Plugins that DON'T implement it just
39+
skip silently — scripts see empty values, preserving pre-v0.9.0
40+
behavior.
41+
42+
### Added — `ScriptArgs` global (FR 1)
43+
44+
CLI tokens between the script path and the trailing output-file
45+
positional are now available to scripts:
46+
47+
```
48+
combridge solidworks run-script audit.csx --mode quick --offline X: -
49+
└────── ScriptArgs ──────┘
50+
```
51+
52+
Inside the script (both `.csx` and `.vbs`):
53+
54+
```csharp
55+
// .csx
56+
foreach (var arg in ScriptArgs) Console.WriteLine(arg);
57+
return ScriptArgs.Length;
58+
```
59+
60+
```vbscript
61+
' .vbs
62+
For i = 0 To UBound(ScriptArgs)
63+
WScript.Echo ScriptArgs(i)
64+
Next
65+
```
66+
67+
Empty array when no extra tokens were passed.
68+
69+
### Added — `Stdin` global (FR 2 item 1)
70+
71+
If the calling process redirected stdin to combridge (a pipeline, a
72+
here-doc, a file redirect), the full stream is read eagerly at command
73+
entry and exposed to the script as `Stdin`. Empty string when stdin
74+
isn't redirected.
75+
76+
```csharp
77+
// ScripTree provider .csx — receives request as JSON on stdin:
78+
var req = JsonSerializer.Deserialize<ProviderRequest>(Stdin);
79+
// ... build choices ...
80+
Console.WriteLine(JsonSerializer.Serialize(new { choices, choice_labels }));
81+
```
82+
83+
**Stdin-timeout trap** (caught during smoke-test, important): the
84+
naive implementation `if (Console.IsInputRedirected)
85+
ReadToEndAsync()` **hangs forever** when stdin is inherited-but-empty
86+
(common when combridge is invoked from bash subprocesses, Task
87+
Scheduler, CI runners). `Console.IsInputRedirected` returns `true`
88+
for "non-terminal" — NOT "has data available." The fix:
89+
`ReadStdinWithTimeoutAsync` uses the underlying stream's `ReadAsync`
90+
with a 250 ms cancellation token, extended per chunk so slow
91+
producers aren't truncated. Real producers deliver the first bytes
92+
in microseconds; empty-inherited-handle invocations collapse to "".
93+
See `personal_rag/claude_code/lesson_20260615_console_isinputredirected_inherited_handle_hang.md`.
94+
95+
### Changed — `Console.Error` no longer redirected to `<out>` (FR 2 item 2)
96+
97+
Pre-v0.9.0 `ScriptHost.RunAsync` did:
98+
99+
```csharp
100+
Console.SetOut(output);
101+
Console.SetError(output); // ← merged stderr into the <out> writer
102+
```
103+
104+
So a script's `Console.Error.WriteLine(...)` corrupted any structured
105+
data on stdout — a provider that needed to emit pure JSON had to
106+
forbid all diagnostics on the success path. v0.9.0 leaves
107+
`Console.Error` alone:
108+
109+
```csharp
110+
Console.SetOut(output);
111+
// Console.Error flows to the process's real stderr (cleaner filter shape)
112+
```
113+
114+
Host-emitted diagnostics (compile errors, `script not found`,
115+
`SCRIPT EXCEPTION`) already go through `output.WriteLine(...)`
116+
directly and are unaffected. Only the SCRIPT's `Console.Error`
117+
changes destination.
118+
119+
### Verified
120+
121+
Full SWBomExcluded provider pattern — stdin in, args in, stdout pure
122+
JSON, stderr diagnostics:
123+
124+
```bash
125+
echo '{"target_file":"foo.SLDDRW"}' | combridge solidworks run-script \
126+
full_filter.csx --x 1 --y 2 /tmp/out.json 2>/tmp/diag.txt
127+
```
128+
129+
Result:
130+
- `/tmp/out.json` contains clean JSON (parser-safe):
131+
`{ "received_target": "foo.SLDDRW", "arg_count": 4, "args": ["--x","1","--y","2"] }`
132+
- `/tmp/diag.txt` contains the diagnostic:
133+
`[diag] received 29 bytes on stdin, 4 args`
134+
- combridge exits 0
135+
136+
Plus the empty-stdin no-hang case (Test 1), real-pipe case (Test 2),
137+
and stderr-split case (Test 3) — all pass.
138+
139+
### Impact on existing scripts
140+
141+
Mostly none — both fields default to empty when unused. The one
142+
behavior change worth noting: any existing `.csx` that wrote
143+
diagnostics via `Console.Error.WriteLine(...)` expecting them to land
144+
in `<out>` will now write to the process's real stderr instead. This
145+
is the Unix-correct behavior; scripts that need the old merge can
146+
explicitly redirect with `Console.SetError(Console.Out)` at the top
147+
of the file.
148+
149+
### Files
150+
151+
Added:
152+
- `src/ComBridge.Core/IScriptContext.cs`
153+
154+
Changed:
155+
- `src/ComBridge.Core/Commands/RunScriptCommand.cs` — populates
156+
`ScriptArgs` + `Stdin` on globals before invoking host;
157+
`ReadStdinWithTimeoutAsync` helper
158+
- `src/ComBridge.Core/ScriptHost.cs` — removed `Console.SetError`
159+
- `src/plugins/ComBridge.Plugins.SolidWorks/SolidWorksPlugin.cs`
160+
`SwGlobals` implements `IScriptContext`
161+
- `src/plugins/ComBridge.Plugins.Excel/ExcelPlugin.cs`
162+
`ExcelGlobals` implements `IScriptContext`
163+
- `src/plugins/ComBridge.Plugins.Word/WordPlugin.cs`
164+
`WdGlobals` implements `IScriptContext`
165+
- `src/plugins/ComBridge.Plugins.PowerPoint/PowerPointPlugin.cs`
166+
`PptGlobals` implements `IScriptContext`
167+
- `src/plugins/ComBridge.Plugins.Outlook/OutlookPlugin.cs`
168+
`OlGlobals` implements `IScriptContext`
169+
- `src/plugins/ComBridge.Plugins.Excel.Mac/XlMacApp.cs`
170+
`XlMacGlobals` implements `IScriptContext`
171+
- `src/plugins/ComBridge.Plugins.Word.Mac/WdMacApp.cs`
172+
`WdMacGlobals` implements `IScriptContext`
173+
- `src/plugins/ComBridge.Plugins.PowerPoint.Mac/PptMacApp.cs`
174+
`PptMacGlobals` implements `IScriptContext`
175+
- `src/plugins/ComBridge.Plugins.Outlook.Mac/OlMacApp.cs`
176+
`OlMacGlobals` implements `IScriptContext`
177+
178+
Both FRs moved to `Complete\` with implementation log stamps.
179+
The `Args` FR's old "Status: PROPOSED" header was preserved with a
180+
note that it had been misfiled prior to actual implementation.
181+
7182
## [0.8.2]`run-script` now propagates the script's `return N` as the process exit code (contract fix)
8183

9184
Closes `FR_runscript_propagate_script_return_value.md`. A `.csx`

src/ComBridge.Core/Commands/RunScriptCommand.cs

Lines changed: 100 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,30 @@ namespace ComBridge.Core.Commands;
44
/// Built-in <c>run-script</c> command. Every plugin gets this for free —
55
/// the host registers it before any plugin-specific commands.
66
/// </summary>
7+
/// <remarks>
8+
/// <para>
9+
/// CLI shape: <c>combridge &lt;plugin&gt; run-script &lt;script&gt; [args...] &lt;out&gt;</c>.
10+
/// The host strips the trailing output-file positional before calling
11+
/// here, so <c>args[0]</c> is the script path and any further tokens
12+
/// (<c>args[1..]</c>) form the script's <see cref="IScriptContext.ScriptArgs"/>.
13+
/// </para>
14+
/// <para>
15+
/// Stdin behavior: when the calling process redirected stdin to
16+
/// combridge (a pipeline or a here-doc), the full stream is read
17+
/// eagerly at command entry and exposed to the script as
18+
/// <see cref="IScriptContext.Stdin"/>. ScripTree's
19+
/// <c>choices_provider</c> contract (which delivers its input as a
20+
/// JSON object on stdin) is the primary motivator — see
21+
/// <c>FR_runscript_stdin_and_stderr_separation.md</c>.
22+
/// </para>
23+
/// </remarks>
724
public sealed class RunScriptCommand : IBridgeCommand
825
{
926
private readonly IComBridgePlugin _plugin;
1027
public RunScriptCommand(IComBridgePlugin plugin) => _plugin = plugin;
1128

1229
public string Name => "run-script";
13-
public string Usage => "run-script <scriptFile.csx> (output file passed as last CLI arg)";
30+
public string Usage => "run-script <scriptFile.{csx|vbs}> [args...] (output file passed as last CLI arg)";
1431

1532
public async Task<int> RunAsync(object comRoot, string[] args, TextWriter output)
1633
{
@@ -19,7 +36,89 @@ public async Task<int> RunAsync(object comRoot, string[] args, TextWriter output
1936
output.WriteLine($"USAGE: {Usage}");
2037
return 64;
2138
}
39+
2240
var globals = _plugin.CreateGlobals(comRoot);
41+
42+
// Populate the host-side I/O channels on globals if the plugin's
43+
// globals class implements IScriptContext (every shipped plugin
44+
// does). Plugins that don't implement it just skip silently —
45+
// their scripts won't see ScriptArgs/Stdin, which matches the
46+
// pre-v0.9.0 behavior.
47+
if (globals is IScriptContext ctx)
48+
{
49+
// Everything between the script path and the trailing output
50+
// file goes to the script as ScriptArgs. Empty array if no
51+
// extra tokens were passed.
52+
ctx.ScriptArgs = args.Length > 1
53+
? args.Skip(1).ToArray()
54+
: Array.Empty<string>();
55+
56+
// Read stdin eagerly if it's redirected, so a script can
57+
// JsonSerializer.Deserialize(Stdin) without stream timing
58+
// concerns. With a short timeout to avoid hangs when stdin
59+
// is "redirected" only because a parent shell inherited a
60+
// non-terminal handle but no producer is actually writing —
61+
// common when combridge is launched from bash subprocesses
62+
// or scheduled tasks. Real producers (ScripTree provider
63+
// pipes, here-docs, file redirects) deliver data within
64+
// microseconds; 250 ms is generous for them, instant
65+
// enough to not hang an empty invocation.
66+
ctx.Stdin = Console.IsInputRedirected
67+
? await ReadStdinWithTimeoutAsync(TimeSpan.FromMilliseconds(250))
68+
: "";
69+
}
70+
2371
return await ScriptHost.RunAsync(_plugin, globals, args[0], output);
2472
}
73+
74+
/// <summary>
75+
/// Read the whole of stdin into a string, but give up if no data
76+
/// arrives within <paramref name="initialWait"/>. After the first
77+
/// chunk has been read the timer is extended per chunk so a slow
78+
/// producer can still deliver a large payload; only the INITIAL
79+
/// wait is bounded.
80+
/// </summary>
81+
/// <remarks>
82+
/// <para>
83+
/// <see cref="Console.IsInputRedirected"/> returns true whenever
84+
/// stdin is anything other than a terminal — including when a
85+
/// parent shell (bash, Task Scheduler, a CI runner) inherits a
86+
/// non-terminal handle to combridge without writing anything.
87+
/// A naive <c>Console.In.ReadToEndAsync()</c> blocks forever in
88+
/// that case because the pipe stays open but empty. This method
89+
/// uses the underlying stream's
90+
/// <see cref="System.IO.Stream.ReadAsync(byte[],int,int,System.Threading.CancellationToken)"/>
91+
/// with a cancellation token so the wait collapses to the timeout
92+
/// when no producer is on the other end.
93+
/// </para>
94+
/// <para>
95+
/// Real producers (ScripTree provider pipes, Bash here-docs, file
96+
/// redirects) deliver the first bytes within microseconds — they
97+
/// never hit the timeout. The 250 ms default is generous; bumping
98+
/// it further has no upside.
99+
/// </para>
100+
/// </remarks>
101+
private static async Task<string> ReadStdinWithTimeoutAsync(TimeSpan initialWait)
102+
{
103+
try
104+
{
105+
using var cts = new CancellationTokenSource(initialWait);
106+
var stream = Console.OpenStandardInput();
107+
using var ms = new MemoryStream();
108+
var buf = new byte[8192];
109+
while (true)
110+
{
111+
int read = await stream.ReadAsync(buf.AsMemory(), cts.Token);
112+
if (read == 0) break;
113+
ms.Write(buf, 0, read);
114+
// Producer is actively writing — extend the timer so a
115+
// multi-chunk payload doesn't get truncated by the
116+
// initial-wait clock.
117+
cts.CancelAfter(initialWait);
118+
}
119+
return System.Text.Encoding.UTF8.GetString(ms.ToArray());
120+
}
121+
catch (OperationCanceledException) { return ""; }
122+
catch { return ""; }
123+
}
25124
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
namespace ComBridge.Core;
2+
3+
/// <summary>
4+
/// Host-injected fields available to <c>run-script</c> scripts on top of
5+
/// the plugin-specific globals. Each plugin's globals class implements
6+
/// this interface so the host can set the fields after construction —
7+
/// the plugin owns the COM-binding properties (e.g. <c>swApp</c>,
8+
/// <c>xlBook</c>); the host owns the I/O channels (<c>ScriptArgs</c>,
9+
/// <c>Stdin</c>).
10+
/// </summary>
11+
/// <remarks>
12+
/// <para>
13+
/// Why an interface rather than a base class: each plugin's globals
14+
/// class is already final-state with init-only COM properties set by
15+
/// its constructor. Switching to a base class would force every
16+
/// plugin's globals constructor to chain through, and any future
17+
/// host-added field becomes a versioning concern across the plugin
18+
/// tree. The interface lets the host blindly cast and set without
19+
/// any plugin contract change.
20+
/// </para>
21+
/// <para>
22+
/// Both fields are populated by <see cref="Commands.RunScriptCommand"/>
23+
/// before the script runs. <c>ScriptArgs</c> comes from the CLI tokens
24+
/// between the script path and the output-file positional;
25+
/// <c>Stdin</c> comes from the process's stdin if redirected (empty
26+
/// string otherwise). Scripts that ignore them are unaffected; callers
27+
/// that pass nothing get empty values.
28+
/// </para>
29+
/// <para>
30+
/// See FR <c>FR_runscript_script_args.md</c> (the argv channel) and
31+
/// FR <c>FR_runscript_stdin_and_stderr_separation.md</c> (the stdin
32+
/// channel + stderr separation). Both shipped in v0.9.0.
33+
/// </para>
34+
/// </remarks>
35+
public interface IScriptContext
36+
{
37+
/// <summary>
38+
/// CLI tokens passed between the script path and the trailing
39+
/// output-file positional. Empty array when no tokens were passed.
40+
/// </summary>
41+
/// <example>
42+
/// <c>combridge solidworks run-script audit.csx --mode quick --offline X: -</c>
43+
/// produces <c>ScriptArgs = ["--mode", "quick", "--offline", "X:"]</c>.
44+
/// </example>
45+
string[] ScriptArgs { get; set; }
46+
47+
/// <summary>
48+
/// The full stdin the process received, as a single string. Empty
49+
/// when stdin was not redirected. Read eagerly at script-start so
50+
/// the script can <c>JsonSerializer.Deserialize</c> it without
51+
/// worrying about stream timing.
52+
/// </summary>
53+
/// <example>
54+
/// A ScripTree <c>choices_provider</c> hands the .csx its request
55+
/// as a JSON blob on stdin; the script does
56+
/// <c>var req = JsonSerializer.Deserialize&lt;Request&gt;(Stdin);</c>
57+
/// and prints the choices JSON on stdout.
58+
/// </example>
59+
string Stdin { get; set; }
60+
}

src/ComBridge.Core/ScriptHost.cs

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -148,11 +148,17 @@ public static async Task<int> RunAsync(
148148
.WithFilePath(Path.GetFullPath(scriptPath))
149149
.WithEmitDebugInformation(true);
150150

151-
// Redirect script Console.* into our output writer.
151+
// Redirect script Console.Out into our output writer. Console.Error
152+
// is left alone (v0.9.0 change): the script's stderr flows to the
153+
// process's real stderr, so a .csx becomes a proper Unix-style
154+
// stdin->stdout(data) + stderr(diag) filter — composable in
155+
// pipelines and usable as a ScripTree provider that emits clean
156+
// JSON on stdout while still logging diagnostics. Host diagnostics
157+
// (compile errors, "script not found", SCRIPT EXCEPTION) go
158+
// through `output.WriteLine(...)` directly and are unaffected.
159+
// See FR_runscript_stdin_and_stderr_separation.md item 2.
152160
var originalOut = Console.Out;
153-
var originalErr = Console.Error;
154161
Console.SetOut(output);
155-
Console.SetError(output);
156162
try
157163
{
158164
// Roslyn's internal scripting host creates its own AssemblyLoadContext
@@ -217,7 +223,6 @@ public static async Task<int> RunAsync(
217223
finally
218224
{
219225
Console.SetOut(originalOut);
220-
Console.SetError(originalErr);
221226
}
222227
}
223228

0 commit comments

Comments
 (0)