fauxnix-cli 0.7.1 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -77,3 +77,57 @@ export interface WordArgs {
77
77
  * shortValues: single-char options that consume a value (e.g. ['n']).
78
78
  */
79
79
  export declare function parseWords(args: Word[], shortValues?: string[], longValues?: string[]): WordArgs;
80
+ export type CommandEffect = 'read' | 'write' | 'delete' | 'network' | 'process';
81
+ export type OptionSupport = 'implemented' | 'unsupported';
82
+ /** One short/long alias group. Unknown options on a spec'd command fail loud. */
83
+ export interface OptionSpec {
84
+ /** Short flag letter without dash (e.g. `'n'`). */
85
+ short?: string;
86
+ /** Long option including dashes (e.g. `'--no-clobber'`). */
87
+ long?: string;
88
+ /** Consumes a following argument (`-n 5`, `--lines=5`). */
89
+ takesValue?: boolean;
90
+ support: OptionSupport;
91
+ /** Extra phrase for unsupported options (`interactive prompt`). */
92
+ reason?: string;
93
+ }
94
+ export interface CommandSpec {
95
+ names: string[];
96
+ options: OptionSpec[];
97
+ effects: CommandEffect[];
98
+ platform?: 'windows-ps51' | 'portable-translate';
99
+ dispatch?: 'translated' | 'native' | 'dynamic';
100
+ /** GNU usage/syntax exit (grep uses 2; cp/mv/rm use 1). */
101
+ usageExit?: number;
102
+ handler: Handler;
103
+ }
104
+ /** Register a spec'd command. Unknown/unsupported options become GNU-style usage errors. */
105
+ export declare function registerSpec(spec: CommandSpec): void;
106
+ export declare function registerSpecs(list: CommandSpec[]): void;
107
+ export declare function lookupSpec(name: string): CommandSpec | undefined;
108
+ /** Unique specs in registration order. */
109
+ export declare function registeredSpecs(): CommandSpec[];
110
+ /** Markdown dump of every CommandSpec — source for docs/command-specs.md. */
111
+ export declare function specsMarkdown(): string;
112
+ export interface ListedCommand {
113
+ name: string;
114
+ spec: null | {
115
+ options: Array<{
116
+ short?: string;
117
+ long?: string;
118
+ takesValue: boolean;
119
+ support: OptionSupport;
120
+ reason?: string;
121
+ }>;
122
+ effects: CommandEffect[];
123
+ platform: 'windows-ps51' | 'portable-translate';
124
+ dispatch: 'translated' | 'native' | 'dynamic';
125
+ };
126
+ }
127
+ /** Capability dump for `fauxnix list --json` / MCP introspection. */
128
+ export declare function listCommandsJson(): ListedCommand[];
129
+ /**
130
+ * Walk argv against a CommandSpec. Returns a PowerShell error script, or
131
+ * null when every option is recognized and implemented.
132
+ */
133
+ export declare function specOptionError(spec: CommandSpec, args: Word[], cmdName: string): string | null;
package/dist/registry.js CHANGED
@@ -153,3 +153,185 @@ export function parseWords(args, shortValues = [], longValues = []) {
153
153
  }
154
154
  return { flags, longs, values, missingValue, operandWords };
155
155
  }
156
+ const specs = new Map();
157
+ /** Register a spec'd command. Unknown/unsupported options become GNU-style usage errors. */
158
+ export function registerSpec(spec) {
159
+ for (const name of spec.names) {
160
+ const wrapped = (args, ctx) => {
161
+ const err = specOptionError(spec, args, name);
162
+ if (err)
163
+ return err;
164
+ return spec.handler(args, ctx);
165
+ };
166
+ registry.set(name, wrapped);
167
+ specs.set(name, spec);
168
+ }
169
+ }
170
+ export function registerSpecs(list) {
171
+ for (const spec of list)
172
+ registerSpec(spec);
173
+ }
174
+ export function lookupSpec(name) {
175
+ return specs.get(name);
176
+ }
177
+ /** Unique specs in registration order. */
178
+ export function registeredSpecs() {
179
+ const seen = new Set();
180
+ const out = [];
181
+ for (const spec of specs.values()) {
182
+ if (seen.has(spec))
183
+ continue;
184
+ seen.add(spec);
185
+ out.push(spec);
186
+ }
187
+ return out;
188
+ }
189
+ /** Markdown dump of every CommandSpec — source for docs/command-specs.md. */
190
+ export function specsMarkdown() {
191
+ const lines = [
192
+ '# Command specs',
193
+ '',
194
+ 'Generated from `CommandSpec`. Unlisted commands still use unchecked `parseWords` (unknown flags ignored). Spec\'d commands fail loud on unknown or unsupported options.',
195
+ '',
196
+ ];
197
+ for (const spec of registeredSpecs()) {
198
+ lines.push('## `' + spec.names.join('` / `') + '`');
199
+ lines.push('');
200
+ lines.push('Effects: ' + spec.effects.map((e) => '`' + e + '`').join(', '));
201
+ lines.push('');
202
+ if (!spec.options.length) {
203
+ lines.push('No options declared.');
204
+ }
205
+ else {
206
+ lines.push('| Option | Value | Support |');
207
+ lines.push('| --- | --- | --- |');
208
+ for (const o of spec.options) {
209
+ const names = [o.short ? '`-' + o.short + '`' : '', o.long ? '`' + o.long + '`' : '']
210
+ .filter((s) => s !== '')
211
+ .join(', ');
212
+ const val = o.takesValue ? 'required' : 'flag';
213
+ const extra = o.reason ? ' (' + o.reason + ')' : '';
214
+ lines.push('| ' + names + ' | ' + val + ' | ' + o.support + extra + ' |');
215
+ }
216
+ }
217
+ lines.push('');
218
+ }
219
+ const unspec = registeredNames().filter((n) => !lookupSpec(n));
220
+ lines.push('## Unspec\'d commands');
221
+ lines.push('');
222
+ lines.push(unspec.map((n) => '`' + n + '`').join(', '));
223
+ lines.push('');
224
+ return lines.join('\n');
225
+ }
226
+ /** Capability dump for `fauxnix list --json` / MCP introspection. */
227
+ export function listCommandsJson() {
228
+ return registeredNames().map((name) => {
229
+ const spec = lookupSpec(name);
230
+ if (!spec)
231
+ return { name, spec: null };
232
+ return {
233
+ name,
234
+ spec: {
235
+ options: spec.options.map((o) => ({
236
+ ...(o.short ? { short: o.short } : {}),
237
+ ...(o.long ? { long: o.long } : {}),
238
+ takesValue: o.takesValue === true,
239
+ support: o.support,
240
+ ...(o.reason ? { reason: o.reason } : {}),
241
+ })),
242
+ effects: spec.effects,
243
+ platform: spec.platform ?? 'windows-ps51',
244
+ dispatch: spec.dispatch ?? 'translated',
245
+ },
246
+ };
247
+ });
248
+ }
249
+ /**
250
+ * Walk argv against a CommandSpec. Returns a PowerShell error script, or
251
+ * null when every option is recognized and implemented.
252
+ */
253
+ export function specOptionError(spec, args, cmdName) {
254
+ const usageExit = spec.usageExit ?? 1;
255
+ const fail = (msg) => optionFail(cmdName, msg, usageExit);
256
+ const shorts = new Map();
257
+ const longs = new Map();
258
+ for (const o of spec.options) {
259
+ if (o.short)
260
+ shorts.set(o.short, o);
261
+ if (o.long)
262
+ longs.set(o.long, o);
263
+ }
264
+ let i = 0;
265
+ let onlyOperands = false;
266
+ while (i < args.length) {
267
+ const t = wordToString(args[i]);
268
+ if (onlyOperands) {
269
+ i++;
270
+ continue;
271
+ }
272
+ if (t === '--') {
273
+ onlyOperands = true;
274
+ i++;
275
+ continue;
276
+ }
277
+ if (t.startsWith('--')) {
278
+ const eq = t.indexOf('=');
279
+ const name = eq >= 0 ? t.slice(0, eq) : t;
280
+ const opt = longs.get(name);
281
+ if (!opt)
282
+ return fail("unrecognized option '" + name + "'");
283
+ if (opt.support === 'unsupported') {
284
+ return fail(unsupportedMsg(opt, name));
285
+ }
286
+ if (!opt.takesValue && eq >= 0) {
287
+ return fail("option '" + name + "' doesn't allow an argument");
288
+ }
289
+ if (opt.takesValue && eq < 0) {
290
+ if (i + 1 < args.length)
291
+ i++;
292
+ else
293
+ return fail("option '" + name + "' requires an argument");
294
+ }
295
+ i++;
296
+ continue;
297
+ }
298
+ if (t.startsWith('-') && t.length > 1 && !/^-?\d/.test(t.slice(1, 2))) {
299
+ const body = t.slice(1);
300
+ for (let c = 0; c < body.length; c++) {
301
+ const ch = body[c];
302
+ const opt = shorts.get(ch);
303
+ if (!opt)
304
+ return fail("invalid option -- '" + ch + "'");
305
+ if (opt.support === 'unsupported') {
306
+ return fail(unsupportedMsg(opt, '-' + ch));
307
+ }
308
+ if (opt.takesValue) {
309
+ const rest = body.slice(c + 1);
310
+ if (!rest) {
311
+ if (i + 1 < args.length)
312
+ i++;
313
+ else
314
+ return fail("option requires an argument -- '" + ch + "'");
315
+ }
316
+ break;
317
+ }
318
+ }
319
+ i++;
320
+ continue;
321
+ }
322
+ i++;
323
+ }
324
+ return null;
325
+ }
326
+ function unsupportedMsg(opt, shown) {
327
+ const reason = opt.reason ? ' (' + opt.reason + ')' : '';
328
+ return "option '" + shown + "' is not supported by fauxnix" + reason;
329
+ }
330
+ function optionFail(cmd, msg, code = 1) {
331
+ return ('[Console]::Error.WriteLine(' +
332
+ psStr(cmd + ': ' + msg) +
333
+ '); [Console]::Error.WriteLine(' +
334
+ psStr("Try '" + cmd + " --help' for more information.") +
335
+ '); $script:fx_exit = ' +
336
+ String(code));
337
+ }
@@ -1209,12 +1209,36 @@ $fx_reader = New-Object System.IO.StreamReader($fx_in, $fx_utf8, $true, 8192, $t
1209
1209
  $fx_proto = New-Object System.IO.StreamWriter($fx_out, $fx_utf8, 8192, $true)
1210
1210
  $fx_proto.NewLine = [string][char]10
1211
1211
  $fx_proto.AutoFlush = $true
1212
- $fx_proto.WriteLine('{"ready":true}')
1212
+ $fx_proto.WriteLine('{"v":2,"type":"ready","capabilities":{"cancel":false,"maxChunkBytes":65536,"stderrMarker":true}}')
1213
+ function fx-b64([byte[]]$bytes, $off, $len) {
1214
+ if ($len -le 0) { return '' }
1215
+ $slice = New-Object byte[] $len
1216
+ [Array]::Copy($bytes, $off, $slice, 0, $len)
1217
+ return [Convert]::ToBase64String($slice)
1218
+ }
1219
+ function fx-emit-chunks($type, $id, [byte[]]$bytes, $limit, [ref]$seq) {
1220
+ $n = 0
1221
+ if ($null -ne $bytes) { $n = $bytes.Length }
1222
+ $use = $n
1223
+ $trunc = $false
1224
+ if ($use -gt $limit) { $use = $limit; $trunc = $true }
1225
+ $off = 0
1226
+ while ($off -lt $use) {
1227
+ $len = $use - $off
1228
+ if ($len -gt 65536) { $len = 65536 }
1229
+ $b64 = fx-b64 $bytes $off $len
1230
+ $fx_proto.WriteLine('{"v":2,"type":"' + $type + '","id":"' + $id + '","seq":' + $seq.Value + ',"dataB64":"' + $b64 + '"}')
1231
+ $seq.Value = $seq.Value + 1
1232
+ $off += $len
1233
+ }
1234
+ return $trunc
1235
+ }
1213
1236
  while ($true) {
1214
1237
  $fx_line = $fx_reader.ReadLine()
1215
1238
  if ($null -eq $fx_line) { break }
1216
1239
  if ($fx_line -eq '') { continue }
1217
1240
  $fx_id = ''
1241
+ $fx_req = $null
1218
1242
  $fx_msOut = $null
1219
1243
  $fx_msErr = $null
1220
1244
  $fx_outW = $null
@@ -1261,13 +1285,47 @@ while ($true) {
1261
1285
  try { [Console]::SetOut($fx_oldOut) } catch {}
1262
1286
  try { [Console]::SetError($fx_oldErr) } catch {}
1263
1287
  }
1264
- $fx_outB64 = ''
1265
- $fx_errB64 = ''
1266
- if ($null -ne $fx_msOut) { $fx_outB64 = [Convert]::ToBase64String($fx_msOut.ToArray()) }
1267
- if ($null -ne $fx_msErr) { $fx_errB64 = [Convert]::ToBase64String($fx_msErr.ToArray()) }
1268
1288
  $fx_code = 0
1269
1289
  try { $fx_code = [int]$script:fx_exit } catch { $fx_code = 1 }
1270
- $fx_res = @{ id = $fx_id; stdoutB64 = $fx_outB64; stderrB64 = $fx_errB64; exitCode = $fx_code }
1271
- $fx_proto.WriteLine(($fx_res | ConvertTo-Json -Compress))
1290
+ $fx_v2 = $false
1291
+ if ($null -ne $fx_req -and $null -ne $fx_req.PSObject.Properties['v']) {
1292
+ if ([int]$fx_req.v -eq 2) { $fx_v2 = $true }
1293
+ }
1294
+ $fx_outBytes = New-Object byte[] 0
1295
+ $fx_errBytes = New-Object byte[] 0
1296
+ if ($null -ne $fx_msOut) { $fx_outBytes = $fx_msOut.ToArray() }
1297
+ if ($null -ne $fx_msErr) { $fx_errBytes = $fx_msErr.ToArray() }
1298
+ if ($fx_v2) {
1299
+ $fx_outLimit = 8388608
1300
+ $fx_errLimit = 1048576
1301
+ if ($null -ne $fx_req.PSObject.Properties['stdoutLimit']) { $fx_outLimit = [int]$fx_req.stdoutLimit }
1302
+ if ($null -ne $fx_req.PSObject.Properties['stderrLimit']) { $fx_errLimit = [int]$fx_req.stderrLimit }
1303
+ $fx_outSeq = 0
1304
+ $fx_errSeq = 0
1305
+ $fx_trunc = $false
1306
+ if (fx-emit-chunks 'stdout' $fx_id $fx_outBytes $fx_outLimit ([ref]$fx_outSeq)) { $fx_trunc = $true }
1307
+ if (fx-emit-chunks 'stderr' $fx_id $fx_errBytes $fx_errLimit ([ref]$fx_errSeq)) { $fx_trunc = $true }
1308
+ $fx_nativeErr = [Console]::OpenStandardError()
1309
+ $fx_mark = $fx_utf8.GetBytes(('FAUXNIX_ERR_END:' + $fx_id + [char]10))
1310
+ $fx_nativeErr.Write($fx_mark, 0, $fx_mark.Length)
1311
+ $fx_nativeErr.Flush()
1312
+ $fx_end = '{"v":2,"type":"end","id":"' + $fx_id + '","exitCode":' + $fx_code + ',"timedOut":false,"cancelled":false,"truncated":'
1313
+ if ($fx_trunc) { $fx_end = $fx_end + 'true}' } else { $fx_end = $fx_end + 'false}' }
1314
+ $fx_proto.WriteLine($fx_end)
1315
+ } else {
1316
+ $fx_outB64 = ''
1317
+ $fx_errB64 = ''
1318
+ if ($fx_outBytes.Length -gt 0) { $fx_outB64 = [Convert]::ToBase64String($fx_outBytes) }
1319
+ if ($fx_errBytes.Length -gt 0) { $fx_errB64 = [Convert]::ToBase64String($fx_errBytes) }
1320
+ $fx_res = @{ id = $fx_id; stdoutB64 = $fx_outB64; stderrB64 = $fx_errB64; exitCode = $fx_code }
1321
+ try {
1322
+ $fx_json = $fx_res | ConvertTo-Json -Compress
1323
+ } catch {
1324
+ $fx_msg = 'fauxnix: host result exceeded ConvertTo-Json MaxJsonLength (~2MB)'
1325
+ $fx_res = @{ id = $fx_id; stdoutB64 = ''; stderrB64 = [Convert]::ToBase64String($fx_utf8.GetBytes($fx_msg)); exitCode = 1 }
1326
+ $fx_json = $fx_res | ConvertTo-Json -Compress
1327
+ }
1328
+ $fx_proto.WriteLine($fx_json)
1329
+ }
1272
1330
  }
1273
1331
  `.trim();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fauxnix-cli",
3
- "version": "0.7.1",
3
+ "version": "0.9.0",
4
4
  "description": "Fauxnix — run Linux-style commands on Windows via deterministic PowerShell translation. No VM, no WSL. MCP server + CLI for AI agents.",
5
5
  "type": "module",
6
6
  "bin": {