@qodalis/cli-files 2.0.0-beta.2 → 2.0.0-beta.21

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.
package/public-api.mjs CHANGED
@@ -1,11 +1,14 @@
1
- import { ICliCompletionProvider_TOKEN, ICliFileTransferService_TOKEN, BrowserFileTransferService, DefaultLibraryAuthor, CliForegroundColor, ICliDragDropService_TOKEN } from '@qodalis/cli-core';
1
+ import { createCliToken, ICliCompletionProvider_TOKEN, ICliFileTransferService_TOKEN, CliAccessDeniedError, BrowserFileTransferService, DefaultLibraryAuthor, CliForegroundColor, writeWrapped, writeUsage, writeExamples, joinShellWords, quoteShellWord, resolveHeaders, cliCanAccess, ICliUserSessionService_TOKEN, ICliUserEventService_TOKEN, ICliDragDropService_TOKEN, splitShellWords, isCliAccessDeniedError, ICliAuthorizationService_TOKEN } from '@qodalis/cli-core';
2
2
 
3
3
  // src/lib/interfaces/i-file-system-service.ts
4
- var IFileSystemService_TOKEN = "cli-file-system-service";
4
+ var IFileSystemService_TOKEN = createCliToken("cli-file-system-service");
5
5
 
6
6
  // src/lib/version.ts
7
- var LIBRARY_VERSION = "2.0.0-beta.2";
7
+ var LIBRARY_VERSION = "2.0.0-beta.21";
8
8
  var API_VERSION = 2;
9
+ function splitArgs(raw) {
10
+ return splitShellWords(raw);
11
+ }
9
12
 
10
13
  // src/lib/processors/cli-ls-command-processor.ts
11
14
  var CliLsCommandProcessor = class {
@@ -30,41 +33,63 @@ var CliLsCommandProcessor = class {
30
33
  description: "Use long listing format",
31
34
  required: false,
32
35
  type: "boolean"
36
+ },
37
+ {
38
+ name: "human-readable",
39
+ aliases: ["h"],
40
+ description: "With -l, print sizes like 1.5K and 2M",
41
+ required: false,
42
+ type: "boolean"
33
43
  }
34
44
  ];
45
+ this.humanReadable = false;
35
46
  }
36
47
  async processCommand(command, context) {
37
- const fs = context.services.get(
48
+ const fs = context.services.getRequired(
38
49
  IFileSystemService_TOKEN
39
50
  );
40
- const targetPath = command.value || fs.getCurrentDirectory();
51
+ const paths = splitArgs(command.value || "");
41
52
  const showAll = command.args["all"] || command.args["a"];
42
53
  const longFormat = command.args["long"] || command.args["l"];
43
- try {
44
- const entries = fs.listDirectory(targetPath);
45
- const filtered = showAll ? entries : entries.filter((e) => !e.name.startsWith("."));
46
- if (filtered.length === 0) {
47
- return;
48
- }
49
- filtered.sort((a, b) => {
50
- if (a.type !== b.type) {
51
- return a.type === "directory" ? -1 : 1;
54
+ this.humanReadable = !!(command.args["human-readable"] || command.args["h"]);
55
+ const targets = paths.length > 0 ? paths : [fs.getCurrentDirectory()];
56
+ const piped = [];
57
+ targets.forEach((targetPath, index) => {
58
+ try {
59
+ const node = fs.getNode(targetPath);
60
+ const entries = node && node.type !== "directory" ? [{ ...node, name: targetPath }] : fs.listDirectory(targetPath);
61
+ const filtered = showAll ? entries : entries.filter((e) => !e.name.startsWith("."));
62
+ if (targets.length > 1) {
63
+ if (index > 0) context.writer.writeln();
64
+ context.writer.writeln(`${targetPath}:`);
52
65
  }
53
- return a.name.localeCompare(b.name);
54
- });
55
- if (longFormat) {
56
- this.writeLongFormat(filtered, context);
57
- } else {
58
- this.writeShortFormat(filtered, context);
66
+ if (filtered.length === 0) {
67
+ return;
68
+ }
69
+ filtered.sort((a, b) => {
70
+ if (a.type !== b.type) {
71
+ return a.type === "directory" ? -1 : 1;
72
+ }
73
+ return a.name.localeCompare(b.name);
74
+ });
75
+ if (longFormat) {
76
+ this.writeLongFormat(filtered, context);
77
+ } else {
78
+ this.writeShortFormat(filtered, context);
79
+ piped.push(...filtered.map((e) => e.name));
80
+ }
81
+ } catch (e) {
82
+ context.writer.writeError(e.message);
59
83
  }
60
- } catch (e) {
61
- context.writer.writeError(e.message);
84
+ });
85
+ if (!longFormat && piped.length > 0) {
86
+ context.process.output(piped.join("\n"));
62
87
  }
63
88
  }
64
89
  writeDescription(context) {
65
90
  context.writer.writeln(this.description);
66
91
  context.writer.writeln();
67
- context.writer.writeln("Usage: ls [path] [--all] [--long]");
92
+ context.writer.writeln("Usage: ls [path...] [-a|--all] [-l|--long] [-h|--human-readable]");
68
93
  }
69
94
  writeLongFormat(entries, context) {
70
95
  const { writer } = context;
@@ -73,7 +98,7 @@ var CliLsCommandProcessor = class {
73
98
  const perms = entry.permissions || (entry.type === "directory" ? "rwxr-xr-x" : "rw-r--r--");
74
99
  const owner = entry.ownership?.uid || "-";
75
100
  const group = entry.ownership?.gid || "-";
76
- const size = entry.type === "file" ? entry.size.toString() : "-";
101
+ const size = entry.type !== "file" ? "-" : this.humanReadable ? this.formatSize(entry.size) : entry.size.toString();
77
102
  const date = new Date(entry.modifiedAt);
78
103
  const dateStr = date.toLocaleDateString("en-US", {
79
104
  month: "short",
@@ -87,6 +112,17 @@ var CliLsCommandProcessor = class {
87
112
  );
88
113
  }
89
114
  }
115
+ formatSize(bytes) {
116
+ const units = ["K", "M", "G", "T"];
117
+ let size = bytes;
118
+ let unit = "";
119
+ for (const next of units) {
120
+ if (size < 1024) break;
121
+ size /= 1024;
122
+ unit = next;
123
+ }
124
+ return unit ? `${size < 10 ? size.toFixed(1) : Math.round(size)}${unit}` : String(bytes);
125
+ }
90
126
  writeShortFormat(entries, context) {
91
127
  const { writer } = context;
92
128
  const names = entries.map(
@@ -104,11 +140,30 @@ var CliCdCommandProcessor = class {
104
140
  this.acceptsRawInput = true;
105
141
  this.metadata = { icon: "\u{1F4C1}", module: "file management" };
106
142
  }
143
+ writeDescription(context) {
144
+ const t = context.translator;
145
+ writeWrapped(
146
+ context,
147
+ t.t("cli.cd.help.change_current_directory_without", "Change the current directory. Without a path it goes to your home directory. Paths can be absolute (/...), relative to where you are, or start with ~ for home.")
148
+ );
149
+ context.writer.writeln();
150
+ writeUsage(context, [
151
+ ["cd", t.t("cli.cd.help.go_home_directory", "Go to your home directory")],
152
+ ["cd <dir>", t.t("cli.cd.help.go_directory", "Go into a directory")],
153
+ ["cd ..", t.t("cli.cd.help.go_up_one_level", "Go up one level")]
154
+ ]);
155
+ context.writer.writeln();
156
+ writeExamples(context, [
157
+ ["cd ~/docs", t.t("cli.cd.help.go_docs_home", "Go to docs in your home")],
158
+ ["cd ../projects", t.t("cli.cd.help.go_sibling_directory", "Go to a sibling directory")],
159
+ ["cd /", t.t("cli.cd.help.go_root", "Go to the root")]
160
+ ]);
161
+ }
107
162
  async processCommand(command, context) {
108
- const fs = context.services.get(
163
+ const fs = context.services.getRequired(
109
164
  IFileSystemService_TOKEN
110
165
  );
111
- const target = command.value || "~";
166
+ const target = splitArgs(command.value || "").join(" ") || "~";
112
167
  try {
113
168
  fs.setCurrentDirectory(target);
114
169
  await fs.persist();
@@ -125,8 +180,24 @@ var CliPwdCommandProcessor = class {
125
180
  this.version = LIBRARY_VERSION;
126
181
  this.metadata = { icon: "\u{1F4C2}", module: "file management" };
127
182
  }
183
+ writeDescription(context) {
184
+ const t = context.translator;
185
+ writeWrapped(
186
+ context,
187
+ t.t("cli.pwd.help.print_full_path_directory", "Print the full path of the directory you are in. Relative paths in other commands start from here.")
188
+ );
189
+ context.writer.writeln();
190
+ writeUsage(context, [
191
+ ["pwd", t.t("cli.pwd.help.show_current_directory", "Show the current directory")]
192
+ ]);
193
+ context.writer.writeln();
194
+ writeExamples(context, [
195
+ ["pwd", t.t("cli.pwd.help.example_home_user", "For example /home/user")],
196
+ ["cd ~/docs; pwd", t.t("cli.pwd.help.prints_home_user_docs", "Prints /home/user/docs")]
197
+ ]);
198
+ }
128
199
  async processCommand(command, context) {
129
- const fs = context.services.get(
200
+ const fs = context.services.getRequired(
130
201
  IFileSystemService_TOKEN
131
202
  );
132
203
  context.writer.writeln(fs.getCurrentDirectory());
@@ -151,22 +222,44 @@ var CliMkdirCommandProcessor = class {
151
222
  }
152
223
  ];
153
224
  }
225
+ writeDescription(context) {
226
+ const t = context.translator;
227
+ writeWrapped(
228
+ context,
229
+ t.t("cli.mkdir.help.create_new_directory_p", "Create a new directory. With -p, any missing parent directories are created too.")
230
+ );
231
+ context.writer.writeln();
232
+ writeUsage(context, [
233
+ ["mkdir <dir...>", t.t("cli.mkdir.help.create_one_more_directories", "Create one or more directories")],
234
+ ["mkdir -p <path>", t.t("cli.mkdir.help.create_directory_missing_parents", "Create a directory and its missing parents")]
235
+ ]);
236
+ context.writer.writeln();
237
+ writeExamples(context, [
238
+ ["mkdir projects", t.t("cli.mkdir.help.create_projects_current_directory", "Create projects in the current directory")],
239
+ ["mkdir -p ~/docs/2026/notes", t.t("cli.mkdir.help.create_whole_path_once", "Create the whole path at once")]
240
+ ]);
241
+ }
154
242
  async processCommand(command, context) {
155
- const fs = context.services.get(
243
+ const fs = context.services.getRequired(
156
244
  IFileSystemService_TOKEN
157
245
  );
158
- const path = command.value;
246
+ const paths = splitArgs(command.value || "");
159
247
  const recursive = command.args["parents"] || command.args["p"];
160
- if (!path) {
248
+ if (paths.length === 0) {
161
249
  context.writer.writeError("mkdir: missing operand");
162
250
  return;
163
251
  }
164
- try {
165
- fs.createDirectory(path, recursive);
166
- await fs.persist();
167
- } catch (e) {
168
- context.writer.writeError(e.message);
252
+ let changed = false;
253
+ for (const path of paths) {
254
+ try {
255
+ if (recursive && fs.isDirectory(path)) continue;
256
+ fs.createDirectory(path, recursive);
257
+ changed = true;
258
+ } catch (e) {
259
+ context.writer.writeError(e.message);
260
+ }
169
261
  }
262
+ if (changed) await fs.persist();
170
263
  }
171
264
  };
172
265
  var CliRmdirCommandProcessor = class {
@@ -179,38 +272,58 @@ var CliRmdirCommandProcessor = class {
179
272
  this.valueRequired = true;
180
273
  this.metadata = { icon: "\u{1F5D1}", module: "file management" };
181
274
  }
275
+ writeDescription(context) {
276
+ const t = context.translator;
277
+ writeWrapped(
278
+ context,
279
+ t.t("cli.rmdir.help.remove_empty_directory_refuses", "Remove an empty directory. It refuses to remove a directory that still contains files; use rm -r for that.")
280
+ );
281
+ context.writer.writeln();
282
+ writeUsage(context, [
283
+ ["rmdir <dir...>", t.t("cli.rmdir.help.remove_empty_directories", "Remove empty directories")]
284
+ ]);
285
+ context.writer.writeln();
286
+ writeExamples(context, [
287
+ ["rmdir empty-folder", t.t("cli.rmdir.help.remove_directory_current_one", "Remove a directory in the current one")],
288
+ ["rmdir ~/docs/old", t.t("cli.rmdir.help.remove_full_path", "Remove by full path")]
289
+ ]);
290
+ }
182
291
  async processCommand(command, context) {
183
- const fs = context.services.get(
292
+ const fs = context.services.getRequired(
184
293
  IFileSystemService_TOKEN
185
294
  );
186
- const path = command.value;
187
- if (!path) {
295
+ const paths = splitArgs(command.value || "");
296
+ if (paths.length === 0) {
188
297
  context.writer.writeError("rmdir: missing operand");
189
298
  return;
190
299
  }
191
- try {
192
- const node = fs.getNode(path);
193
- if (!node) {
194
- context.writer.writeError(
195
- `rmdir: ${path}: No such file or directory`
196
- );
197
- return;
198
- }
199
- if (node.type !== "directory") {
200
- context.writer.writeError(`rmdir: ${path}: Not a directory`);
201
- return;
202
- }
203
- if (node.children && node.children.length > 0) {
204
- context.writer.writeError(
205
- `rmdir: ${path}: Directory not empty`
206
- );
207
- return;
300
+ let changed = false;
301
+ for (const path of paths) {
302
+ try {
303
+ const node = fs.getNode(path);
304
+ if (!node) {
305
+ context.writer.writeError(
306
+ `rmdir: ${path}: No such file or directory`
307
+ );
308
+ continue;
309
+ }
310
+ if (node.type !== "directory") {
311
+ context.writer.writeError(`rmdir: ${path}: Not a directory`);
312
+ continue;
313
+ }
314
+ if (node.children && node.children.length > 0) {
315
+ context.writer.writeError(
316
+ `rmdir: ${path}: Directory not empty`
317
+ );
318
+ continue;
319
+ }
320
+ fs.remove(path, true);
321
+ changed = true;
322
+ } catch (e) {
323
+ context.writer.writeError(e.message);
208
324
  }
209
- fs.remove(path);
210
- await fs.persist();
211
- } catch (e) {
212
- context.writer.writeError(e.message);
213
325
  }
326
+ if (changed) await fs.persist();
214
327
  }
215
328
  };
216
329
  var CliTouchCommandProcessor = class {
@@ -223,23 +336,56 @@ var CliTouchCommandProcessor = class {
223
336
  this.valueRequired = true;
224
337
  this.metadata = { icon: "\u{1F4C4}", module: "file management" };
225
338
  }
339
+ writeDescription(context) {
340
+ const t = context.translator;
341
+ writeWrapped(
342
+ context,
343
+ t.t("cli.touch.help.create_empty_file_update", "Create an empty file, or update the modified time of a file that already exists. The parent directory must already exist.")
344
+ );
345
+ context.writer.writeln();
346
+ writeUsage(context, [
347
+ ["touch <file...>", t.t("cli.touch.help.create_files_refresh_their", "Create the files, or refresh their timestamps")]
348
+ ]);
349
+ context.writer.writeln();
350
+ writeExamples(context, [
351
+ ["touch notes.txt", t.t("cli.touch.help.create_empty_notes_txt", "Create an empty notes.txt")],
352
+ ["touch ~/docs/todo.md", t.t("cli.touch.help.create_file_docs", "Create a file in ~/docs")]
353
+ ]);
354
+ }
226
355
  async processCommand(command, context) {
227
- const fs = context.services.get(
356
+ const fs = context.services.getRequired(
228
357
  IFileSystemService_TOKEN
229
358
  );
230
- const path = command.value;
231
- if (!path) {
359
+ const paths = splitArgs(command.value || "");
360
+ if (paths.length === 0) {
232
361
  context.writer.writeError("touch: missing file operand");
233
362
  return;
234
363
  }
235
- try {
236
- fs.createFile(path);
237
- await fs.persist();
238
- } catch (e) {
239
- context.writer.writeError(e.message);
364
+ let changed = false;
365
+ for (const path of paths) {
366
+ try {
367
+ fs.createFile(path);
368
+ changed = true;
369
+ } catch (e) {
370
+ context.writer.writeError(e.message);
371
+ }
240
372
  }
373
+ if (changed) await fs.persist();
241
374
  }
242
375
  };
376
+
377
+ // src/lib/utils/lines.ts
378
+ function splitLines(content) {
379
+ if (content === "") return [];
380
+ const lines = content.split("\n");
381
+ if (lines[lines.length - 1] === "") lines.pop();
382
+ return lines;
383
+ }
384
+ function pipedText(data) {
385
+ return typeof data === "string" ? data : JSON.stringify(data);
386
+ }
387
+
388
+ // src/lib/processors/cli-cat-command-processor.ts
243
389
  var CliCatCommandProcessor = class {
244
390
  constructor() {
245
391
  this.command = "cat";
@@ -250,22 +396,58 @@ var CliCatCommandProcessor = class {
250
396
  this.valueRequired = true;
251
397
  this.metadata = { icon: "\u{1F4D6}", module: "file management" };
252
398
  }
399
+ writeDescription(context) {
400
+ const t = context.translator;
401
+ writeWrapped(
402
+ context,
403
+ t.t("cli.cat.help.print_contents_one_more", "Print the contents of one or more files, joined in order, or of piped input. Pipe the output into other commands to search, sort or count it.")
404
+ );
405
+ context.writer.writeln();
406
+ writeUsage(context, [
407
+ ["cat <file...>", t.t("cli.cat.help.print_files_one_after", "Print files one after another")],
408
+ ["cat <file> | <command>", t.t("cli.cat.help.send_file_another_command", "Send a file to another command")]
409
+ ]);
410
+ context.writer.writeln();
411
+ writeExamples(context, [
412
+ ["cat notes.txt", t.t("cli.cat.help.show_whole_file", "Show the whole file")],
413
+ ["cat app.log | grep error", t.t("cli.cat.help.show_only_lines_error", "Show only lines with error")],
414
+ ["cat names.txt | sort", t.t("cli.cat.help.print_lines_sorted", "Print the lines sorted")]
415
+ ]);
416
+ }
253
417
  async processCommand(command, context) {
254
- const fs = context.services.get(
418
+ const fs = context.services.getRequired(
255
419
  IFileSystemService_TOKEN
256
420
  );
257
- const path = command.value;
258
- if (!path) {
421
+ const paths = splitArgs(command.value || "");
422
+ if (paths.length === 0) {
423
+ if (command.data != null) {
424
+ context.writer.writeln(pipedText(command.data));
425
+ return;
426
+ }
259
427
  context.writer.writeError("cat: missing file operand");
260
428
  return;
261
429
  }
262
- try {
263
- const content = fs.readFile(path);
264
- if (content !== null) {
265
- context.writer.writeln(content);
430
+ let output = "";
431
+ for (const path of paths) {
432
+ try {
433
+ if (fs.isDirectory(path)) {
434
+ context.writer.writeError(`cat: ${path}: Is a directory`);
435
+ continue;
436
+ }
437
+ const content = fs.readFile(path);
438
+ if (content === null) {
439
+ context.writer.writeError(
440
+ `cat: ${path}: No such file or directory`
441
+ );
442
+ continue;
443
+ }
444
+ output += content;
445
+ } catch (e) {
446
+ context.writer.writeError(e.message);
266
447
  }
267
- } catch (e) {
268
- context.writer.writeError(e.message);
448
+ }
449
+ if (output !== "") {
450
+ context.writer.writeln(output.replace(/\n$/, ""));
269
451
  }
270
452
  }
271
453
  };
@@ -279,60 +461,33 @@ var CliEchoCommandProcessor = class {
279
461
  this.extendsProcessor = true;
280
462
  this.metadata = { icon: "\u{1F4AC}", module: "file management" };
281
463
  }
464
+ writeDescription(context) {
465
+ const t = context.translator;
466
+ writeWrapped(
467
+ context,
468
+ t.t("cli.echo.help.print_text_terminal_add", "Print text to the terminal. Add > file to save it to a file (replacing it) or >> file to add it to the end.")
469
+ );
470
+ context.writer.writeln();
471
+ writeUsage(context, [
472
+ ["echo <text>", t.t("cli.echo.help.print_text", "Print text")],
473
+ ["echo <text> > <file>", t.t("cli.echo.help.write_text_file", "Write text to a file")],
474
+ ["echo <text> >> <file>", t.t("cli.echo.help.append_text_file", "Append text to a file")]
475
+ ]);
476
+ context.writer.writeln();
477
+ writeExamples(context, [
478
+ ["echo Hello world", t.t("cli.echo.help.print_greeting", "Print a greeting")],
479
+ ["echo first line > notes.txt", t.t("cli.echo.help.create_replace_notes_txt", "Create or replace notes.txt")],
480
+ ["echo another line >> notes.txt", t.t("cli.echo.help.add_line", "Add a line to it")]
481
+ ]);
482
+ }
282
483
  async processCommand(command, context) {
283
- const afterEcho = command.value || "";
284
- const hasRedirection = />>?\s*.+$/.test(afterEcho);
285
- if (!hasRedirection) {
286
- if (this.originalProcessor) {
287
- await this.originalProcessor.processCommand(command, context);
288
- } else {
289
- context.writer.writeln(afterEcho);
290
- }
484
+ if (this.originalProcessor) {
485
+ await this.originalProcessor.processCommand(command, context);
291
486
  return;
292
487
  }
293
- const fs = context.services.get(
294
- IFileSystemService_TOKEN
295
- );
296
- let text;
297
- let filePath;
298
- let append = false;
299
- const appendMatch = afterEcho.match(/^(.*?)\s*>>\s*(.+)$/);
300
- const overwriteMatch = afterEcho.match(/^(.*?)\s*>\s*(.+)$/);
301
- if (appendMatch) {
302
- text = appendMatch[1].trim();
303
- filePath = appendMatch[2].trim();
304
- append = true;
305
- } else {
306
- text = overwriteMatch[1].trim();
307
- filePath = overwriteMatch[2].trim();
308
- }
309
- if (text.startsWith('"') && text.endsWith('"') || text.startsWith("'") && text.endsWith("'")) {
310
- text = text.slice(1, -1);
311
- }
312
- text = text.replace(/\\([\\nrtv0])/g, (_, ch) => {
313
- switch (ch) {
314
- case "n":
315
- return "\n";
316
- case "t":
317
- return " ";
318
- case "r":
319
- return "\r";
320
- case "v":
321
- return "\v";
322
- case "0":
323
- return "\0";
324
- case "\\":
325
- return "\\";
326
- default:
327
- return ch;
328
- }
329
- });
330
- try {
331
- fs.writeFile(filePath, text + "\n", append);
332
- await fs.persist();
333
- } catch (e) {
334
- context.writer.writeError(e.message);
335
- }
488
+ const text = joinShellWords(command.value || "");
489
+ context.writer.writeln(text);
490
+ context.process.output(text);
336
491
  }
337
492
  };
338
493
  var CliRmCommandProcessor = class {
@@ -361,35 +516,58 @@ var CliRmCommandProcessor = class {
361
516
  }
362
517
  ];
363
518
  }
519
+ writeDescription(context) {
520
+ const t = context.translator;
521
+ writeWrapped(
522
+ context,
523
+ t.t("cli.rm.help.delete_file_directories_only", "Delete a file. Directories are only removed with -r, which deletes everything inside them. Deleted files cannot be recovered.")
524
+ );
525
+ context.writer.writeln();
526
+ writeUsage(context, [
527
+ ["rm <file...>", t.t("cli.rm.help.delete_one_more_files", "Delete one or more files")],
528
+ ["rm -r <dir>", t.t("cli.rm.help.delete_directory_contents", "Delete a directory and its contents")],
529
+ ["rm -f <file>", t.t("cli.rm.help.delete_without_error_if", "Delete without an error if it is missing")]
530
+ ]);
531
+ context.writer.writeln();
532
+ writeExamples(context, [
533
+ ["rm notes.bak", t.t("cli.rm.help.delete_one_file", "Delete one file")],
534
+ ["rm -r old-project", t.t("cli.rm.help.delete_whole_directory", "Delete a whole directory")],
535
+ ["rm -f maybe.txt", t.t("cli.rm.help.quiet_if_file_does", "Quiet if the file does not exist")]
536
+ ]);
537
+ }
364
538
  async processCommand(command, context) {
365
- const fs = context.services.get(
539
+ const fs = context.services.getRequired(
366
540
  IFileSystemService_TOKEN
367
541
  );
368
- const path = command.value;
542
+ const paths = splitArgs(command.value || "");
369
543
  const recursive = command.args["recursive"] || command.args["r"] || command.args["R"];
370
544
  const force = command.args["force"] || command.args["f"];
371
- if (!path) {
372
- context.writer.writeError("rm: missing operand");
545
+ if (paths.length === 0) {
546
+ if (!force) context.writer.writeError("rm: missing operand");
373
547
  return;
374
548
  }
375
- try {
376
- if (!fs.exists(path)) {
377
- if (!force) {
378
- context.writer.writeError(
379
- `rm: ${path}: No such file or directory`
380
- );
549
+ let changed = false;
550
+ for (const path of paths) {
551
+ try {
552
+ if (!fs.exists(path)) {
553
+ if (!force) {
554
+ context.writer.writeError(
555
+ `rm: ${path}: No such file or directory`
556
+ );
557
+ }
558
+ continue;
381
559
  }
382
- return;
383
- }
384
- if (fs.isDirectory(path) && !recursive) {
385
- context.writer.writeError(`rm: ${path}: Is a directory`);
386
- return;
560
+ if (fs.isDirectory(path) && !recursive) {
561
+ context.writer.writeError(`rm: ${path}: Is a directory`);
562
+ continue;
563
+ }
564
+ fs.remove(path, recursive);
565
+ changed = true;
566
+ } catch (e) {
567
+ context.writer.writeError(e.message);
387
568
  }
388
- fs.remove(path, recursive);
389
- await fs.persist();
390
- } catch (e) {
391
- context.writer.writeError(e.message);
392
569
  }
570
+ if (changed) await fs.persist();
393
571
  }
394
572
  };
395
573
  var CliCpCommandProcessor = class {
@@ -407,11 +585,37 @@ var CliCpCommandProcessor = class {
407
585
  description: "Copy directories recursively",
408
586
  required: false,
409
587
  type: "boolean"
588
+ },
589
+ {
590
+ name: "force",
591
+ aliases: ["f"],
592
+ description: "Overwrite existing files (always done; accepted for -rf)",
593
+ required: false,
594
+ type: "boolean"
410
595
  }
411
596
  ];
412
597
  }
598
+ writeDescription(context) {
599
+ const t = context.translator;
600
+ writeWrapped(
601
+ context,
602
+ t.t("cli.cp.help.copy_file_new_path", "Copy a file to a new path, or into an existing directory. Copying a directory needs -r, which copies everything inside it. A file already at the destination is replaced.")
603
+ );
604
+ context.writer.writeln();
605
+ writeUsage(context, [
606
+ ["cp <source> <dest>", t.t("cli.cp.help.copy_file_new_name", "Copy a file to a new name")],
607
+ ["cp <source...> <dir>", t.t("cli.cp.help.copy_files_directory", "Copy files into a directory")],
608
+ ["cp -r <dir> <dest>", t.t("cli.cp.help.copy_directory_contents", "Copy a directory and its contents")]
609
+ ]);
610
+ context.writer.writeln();
611
+ writeExamples(context, [
612
+ ["cp notes.txt notes.bak", t.t("cli.cp.help.make_backup_copy", "Make a backup copy")],
613
+ ["cp report.txt ~/docs", t.t("cli.cp.help.copy_docs", "Copy into ~/docs")],
614
+ ["cp -r project project-copy", t.t("cli.cp.help.duplicate_whole_directory", "Duplicate a whole directory")]
615
+ ]);
616
+ }
413
617
  async processCommand(command, context) {
414
- const fs = context.services.get(
618
+ const fs = context.services.getRequired(
415
619
  IFileSystemService_TOKEN
416
620
  );
417
621
  const recursive = command.args["recursive"] || command.args["r"] || command.args["R"];
@@ -423,18 +627,26 @@ var CliCpCommandProcessor = class {
423
627
  );
424
628
  return;
425
629
  }
426
- const src = parts[0];
427
- const dest = parts[1];
428
- try {
429
- fs.copy(src, dest, recursive);
430
- await fs.persist();
431
- } catch (e) {
432
- context.writer.writeError(e.message);
630
+ const dest = parts[parts.length - 1];
631
+ const sources = parts.slice(0, -1);
632
+ if (sources.length > 1 && !fs.isDirectory(dest)) {
633
+ context.writer.writeError(`cp: target '${dest}' is not a directory`);
634
+ return;
635
+ }
636
+ let changed = false;
637
+ for (const src of sources) {
638
+ try {
639
+ fs.copy(src, dest, recursive);
640
+ changed = true;
641
+ } catch (e) {
642
+ context.writer.writeError(e.message);
643
+ }
433
644
  }
645
+ if (changed) await fs.persist();
434
646
  }
435
647
  parseArgs(command) {
436
648
  const raw = command.value || "";
437
- return raw.split(/\s+/).filter((t) => t && !t.startsWith("--") && !t.startsWith("-"));
649
+ return splitArgs(raw).filter(Boolean);
438
650
  }
439
651
  };
440
652
  var CliMvCommandProcessor = class {
@@ -446,8 +658,26 @@ var CliMvCommandProcessor = class {
446
658
  this.acceptsRawInput = true;
447
659
  this.metadata = { icon: "\u{1F4E6}", module: "file management" };
448
660
  }
661
+ writeDescription(context) {
662
+ const t = context.translator;
663
+ writeWrapped(
664
+ context,
665
+ t.t("cli.mv.help.move_rename_file_directory", "Move or rename a file or directory. If the destination is an existing directory, the source is moved into it; otherwise it is renamed. A file already at the destination is replaced.")
666
+ );
667
+ context.writer.writeln();
668
+ writeUsage(context, [
669
+ ["mv <source> <dest>", t.t("cli.mv.help.rename_file_directory", "Rename a file or directory")],
670
+ ["mv <source...> <dir>", t.t("cli.mv.help.move_them_directory", "Move them into a directory")]
671
+ ]);
672
+ context.writer.writeln();
673
+ writeExamples(context, [
674
+ ["mv draft.txt final.txt", t.t("cli.mv.help.rename_file", "Rename a file")],
675
+ ["mv final.txt ~/docs", t.t("cli.mv.help.move_docs", "Move it into ~/docs")],
676
+ ["mv old-dir new-dir", t.t("cli.mv.help.rename_directory", "Rename a directory")]
677
+ ]);
678
+ }
449
679
  async processCommand(command, context) {
450
- const fs = context.services.get(
680
+ const fs = context.services.getRequired(
451
681
  IFileSystemService_TOKEN
452
682
  );
453
683
  const parts = this.parseArgs(command);
@@ -456,20 +686,36 @@ var CliMvCommandProcessor = class {
456
686
  context.writer.writeln("Usage: mv <source> <destination>");
457
687
  return;
458
688
  }
459
- const src = parts[0];
460
- const dest = parts[1];
461
- try {
462
- fs.move(src, dest);
463
- await fs.persist();
464
- } catch (e) {
465
- context.writer.writeError(e.message);
689
+ const dest = parts[parts.length - 1];
690
+ const sources = parts.slice(0, -1);
691
+ if (sources.length > 1 && !fs.isDirectory(dest)) {
692
+ context.writer.writeError(`mv: target '${dest}' is not a directory`);
693
+ return;
466
694
  }
695
+ let changed = false;
696
+ for (const src of sources) {
697
+ try {
698
+ fs.move(src, dest);
699
+ changed = true;
700
+ } catch (e) {
701
+ context.writer.writeError(e.message);
702
+ }
703
+ }
704
+ if (changed) await fs.persist();
467
705
  }
468
706
  parseArgs(command) {
469
707
  const raw = command.value || "";
470
- return raw.split(/\s+/).filter((t) => t && !t.startsWith("--") && !t.startsWith("-"));
708
+ return splitArgs(raw).filter(Boolean);
471
709
  }
472
710
  };
711
+ function readableChildren(fs, path) {
712
+ try {
713
+ return fs.listDirectory(path);
714
+ } catch (e) {
715
+ if (isCliAccessDeniedError(e)) return null;
716
+ throw e;
717
+ }
718
+ }
473
719
  var CliTreeCommandProcessor = class {
474
720
  constructor() {
475
721
  this.command = "tree";
@@ -488,11 +734,29 @@ var CliTreeCommandProcessor = class {
488
734
  }
489
735
  ];
490
736
  }
737
+ writeDescription(context) {
738
+ const t = context.translator;
739
+ writeWrapped(
740
+ context,
741
+ t.t("cli.tree.help.draw_directory_everything_below", "Draw a directory and everything below it as a tree, directories first, followed by a count of directories and files. Defaults to the current directory.")
742
+ );
743
+ context.writer.writeln();
744
+ writeUsage(context, [
745
+ ["tree [dir]", t.t("cli.tree.help.show_whole_tree", "Show the whole tree")],
746
+ ["tree -L <n> [dir]", t.t("cli.tree.help.only_levels_deep", "Only <n> levels deep")]
747
+ ]);
748
+ context.writer.writeln();
749
+ writeExamples(context, [
750
+ ["tree", t.t("cli.tree.help.tree_current_directory", "Tree of the current directory")],
751
+ ["tree ~/docs", t.t("cli.tree.help.tree_docs", "Tree of ~/docs")],
752
+ ["tree -L 2", t.t("cli.tree.help.top_two_levels_only", "Top two levels only")]
753
+ ]);
754
+ }
491
755
  async processCommand(command, context) {
492
- const fs = context.services.get(
756
+ const fs = context.services.getRequired(
493
757
  IFileSystemService_TOKEN
494
758
  );
495
- const targetPath = command.value || fs.getCurrentDirectory();
759
+ const targetPath = splitArgs(command.value || "").join(" ") || fs.getCurrentDirectory();
496
760
  const maxDepth = command.args["depth"] ? parseInt(command.args["depth"]) : command.args["L"] ? parseInt(command.args["L"]) : Infinity;
497
761
  try {
498
762
  const node = fs.getNode(targetPath);
@@ -516,7 +780,8 @@ var CliTreeCommandProcessor = class {
516
780
  )
517
781
  );
518
782
  const counts = { dirs: 0, files: 0 };
519
- this.printTree(node, "", true, 0, maxDepth, context, counts);
783
+ this.fs = fs;
784
+ this.printTree(node, "", true, 0, maxDepth, context, counts, resolvedPath);
520
785
  context.writer.writeln();
521
786
  context.writer.writeln(
522
787
  `${counts.dirs} directories, ${counts.files} files`
@@ -525,11 +790,15 @@ var CliTreeCommandProcessor = class {
525
790
  context.writer.writeError(e.message);
526
791
  }
527
792
  }
528
- printTree(node, prefix, isRoot, depth, maxDepth, context, counts) {
529
- if (!node.children || depth >= maxDepth) {
793
+ printTree(node, prefix, isRoot, depth, maxDepth, context, counts, path = "/") {
794
+ if (depth >= maxDepth) {
530
795
  return;
531
796
  }
532
- const children = [...node.children].sort((a, b) => {
797
+ const visible = this.fs ? readableChildren(this.fs, path) : node.children;
798
+ if (!visible) {
799
+ return;
800
+ }
801
+ const children = [...visible].sort((a, b) => {
533
802
  if (a.type !== b.type) {
534
803
  return a.type === "directory" ? -1 : 1;
535
804
  }
@@ -554,7 +823,8 @@ var CliTreeCommandProcessor = class {
554
823
  depth + 1,
555
824
  maxDepth,
556
825
  context,
557
- counts
826
+ counts,
827
+ path === "/" ? `/${child.name}` : `${path}/${child.name}`
558
828
  );
559
829
  } else {
560
830
  counts.files++;
@@ -581,21 +851,40 @@ var CliHeadCommandProcessor = class {
581
851
  }
582
852
  ];
583
853
  }
854
+ writeDescription(context) {
855
+ const t = context.translator;
856
+ writeWrapped(
857
+ context,
858
+ t.t("cli.head.help.print_first_lines_file", "Print the first lines of a file, 10 by default. Works on piped input too. With several files, each one gets a ==> name <== header.")
859
+ );
860
+ context.writer.writeln();
861
+ writeUsage(context, [
862
+ ["head <file>", t.t("cli.head.help.first_10_lines", "First 10 lines")],
863
+ ["head -n <count> <file>", t.t("cli.head.help.first_lines", "First <count> lines")],
864
+ ["<command> | head", t.t("cli.head.help.first_lines_piped_input", "First lines of piped input")]
865
+ ]);
866
+ context.writer.writeln();
867
+ writeExamples(context, [
868
+ ["head notes.txt", t.t("cli.head.help.show_start_file", "Show the start of a file")],
869
+ ["head -n 3 a.txt b.txt", t.t("cli.head.help.first_3_lines_each", "First 3 lines of each file")],
870
+ ["cat app.log | head -n 20", t.t("cli.head.help.first_20_lines_log", "First 20 lines of the log")]
871
+ ]);
872
+ }
584
873
  async processCommand(command, context) {
585
- const fs = context.services.get(
874
+ const fs = context.services.getRequired(
586
875
  IFileSystemService_TOKEN
587
876
  );
588
- const count = parseInt(
589
- command.args["lines"] || command.args["n"] || "10",
590
- 10
591
- );
877
+ const countArg = command.args["lines"] ?? command.args["n"] ?? Object.keys(command.args).find((k) => /^\d+$/.test(k));
878
+ const parsedCount = parseInt(String(countArg ?? "10"), 10);
879
+ const count = isNaN(parsedCount) ? 10 : Math.abs(parsedCount);
592
880
  const paths = this.parsePaths(command);
593
881
  if (paths.length === 0) {
594
882
  if (command.data != null) {
595
- const content = typeof command.data === "string" ? command.data : JSON.stringify(command.data);
596
- const lines = content.split("\n");
883
+ const lines = splitLines(pipedText(command.data));
597
884
  const selected = lines.slice(0, count);
598
- context.writer.writeln(selected.join("\n"));
885
+ if (selected.length > 0) {
886
+ context.writer.writeln(selected.join("\n"));
887
+ }
599
888
  return;
600
889
  }
601
890
  context.writer.writeError("head: missing file operand");
@@ -614,9 +903,11 @@ var CliHeadCommandProcessor = class {
614
903
  if (i > 0) context.writer.writeln();
615
904
  context.writer.writeln(`==> ${path} <==`);
616
905
  }
617
- const lines = content.split("\n");
906
+ const lines = splitLines(content);
618
907
  const selected = lines.slice(0, count);
619
- context.writer.writeln(selected.join("\n"));
908
+ if (selected.length > 0) {
909
+ context.writer.writeln(selected.join("\n"));
910
+ }
620
911
  } catch (e) {
621
912
  context.writer.writeError(`head: ${e.message}`);
622
913
  }
@@ -624,7 +915,7 @@ var CliHeadCommandProcessor = class {
624
915
  }
625
916
  parsePaths(command) {
626
917
  const raw = command.value || "";
627
- return raw.split(/\s+/).filter(Boolean);
918
+ return splitArgs(raw).filter(Boolean);
628
919
  }
629
920
  };
630
921
  var CliTailCommandProcessor = class {
@@ -646,21 +937,40 @@ var CliTailCommandProcessor = class {
646
937
  }
647
938
  ];
648
939
  }
940
+ writeDescription(context) {
941
+ const t = context.translator;
942
+ writeWrapped(
943
+ context,
944
+ t.t("cli.tail.help.print_last_lines_file", "Print the last lines of a file, 10 by default. Works on piped input too. With several files, each one gets a ==> name <== header.")
945
+ );
946
+ context.writer.writeln();
947
+ writeUsage(context, [
948
+ ["tail <file>", t.t("cli.tail.help.last_10_lines", "Last 10 lines")],
949
+ ["tail -n <count> <file>", t.t("cli.tail.help.last_lines", "Last <count> lines")],
950
+ ["<command> | tail", t.t("cli.tail.help.last_lines_piped_input", "Last lines of piped input")]
951
+ ]);
952
+ context.writer.writeln();
953
+ writeExamples(context, [
954
+ ["tail app.log", t.t("cli.tail.help.show_end_log", "Show the end of a log")],
955
+ ["tail -n 5 notes.txt", t.t("cli.tail.help.last_5_lines", "Last 5 lines")],
956
+ ["cat app.log | tail -n 20", t.t("cli.tail.help.last_20_lines_log", "Last 20 lines of the log")]
957
+ ]);
958
+ }
649
959
  async processCommand(command, context) {
650
- const fs = context.services.get(
960
+ const fs = context.services.getRequired(
651
961
  IFileSystemService_TOKEN
652
962
  );
653
- const count = parseInt(
654
- command.args["lines"] || command.args["n"] || "10",
655
- 10
656
- );
963
+ const countArg = command.args["lines"] ?? command.args["n"] ?? Object.keys(command.args).find((k) => /^\d+$/.test(k));
964
+ const parsedCount = parseInt(String(countArg ?? "10"), 10);
965
+ const count = isNaN(parsedCount) ? 10 : Math.abs(parsedCount);
657
966
  const paths = this.parsePaths(command);
658
967
  if (paths.length === 0) {
659
968
  if (command.data != null) {
660
- const content = typeof command.data === "string" ? command.data : JSON.stringify(command.data);
661
- const lines = content.split("\n");
662
- const selected = lines.slice(-count);
663
- context.writer.writeln(selected.join("\n"));
969
+ const lines = splitLines(pipedText(command.data));
970
+ const selected = count > 0 ? lines.slice(-count) : [];
971
+ if (selected.length > 0) {
972
+ context.writer.writeln(selected.join("\n"));
973
+ }
664
974
  return;
665
975
  }
666
976
  context.writer.writeError("tail: missing file operand");
@@ -679,9 +989,11 @@ var CliTailCommandProcessor = class {
679
989
  if (i > 0) context.writer.writeln();
680
990
  context.writer.writeln(`==> ${path} <==`);
681
991
  }
682
- const lines = content.split("\n");
683
- const selected = lines.slice(-count);
684
- context.writer.writeln(selected.join("\n"));
992
+ const lines = splitLines(content);
993
+ const selected = count > 0 ? lines.slice(-count) : [];
994
+ if (selected.length > 0) {
995
+ context.writer.writeln(selected.join("\n"));
996
+ }
685
997
  } catch (e) {
686
998
  context.writer.writeError(`tail: ${e.message}`);
687
999
  }
@@ -689,7 +1001,7 @@ var CliTailCommandProcessor = class {
689
1001
  }
690
1002
  parsePaths(command) {
691
1003
  const raw = command.value || "";
692
- return raw.split(/\s+/).filter(Boolean);
1004
+ return splitArgs(raw).filter(Boolean);
693
1005
  }
694
1006
  };
695
1007
  var CliWcCommandProcessor = class {
@@ -725,8 +1037,30 @@ var CliWcCommandProcessor = class {
725
1037
  }
726
1038
  ];
727
1039
  }
1040
+ writeDescription(context) {
1041
+ const t = context.translator;
1042
+ writeWrapped(
1043
+ context,
1044
+ t.t("cli.wc.help.count_lines_words_characters", "Count lines, words and characters in files or piped input. With several files, a total line is printed at the end.")
1045
+ );
1046
+ context.writer.writeln();
1047
+ writeUsage(context, [
1048
+ ["wc <file...>", t.t("cli.wc.help.lines_words_characters", "Lines, words and characters")],
1049
+ ["wc -l <file>", t.t("cli.wc.help.only_line_count", "Only the line count")],
1050
+ ["<command> | wc", t.t("cli.wc.help.count_piped_input", "Count piped input")]
1051
+ ]);
1052
+ context.writer.writeln();
1053
+ writeExamples(context, [
1054
+ ["wc notes.txt", t.t("cli.wc.help.all_three_counts", "All three counts")],
1055
+ ["wc -l a.txt b.txt", t.t("cli.wc.help.line_counts_total", "Line counts with a total")],
1056
+ [
1057
+ "cat app.log | grep error | wc -l",
1058
+ t.t("cli.wc.help.how_many_lines_mention", "How many lines mention error")
1059
+ ]
1060
+ ]);
1061
+ }
728
1062
  async processCommand(command, context) {
729
- const fs = context.services.get(
1063
+ const fs = context.services.getRequired(
730
1064
  IFileSystemService_TOKEN
731
1065
  );
732
1066
  const showLines = command.args["lines"] || command.args["l"] || false;
@@ -736,10 +1070,9 @@ var CliWcCommandProcessor = class {
736
1070
  const paths = this.parsePaths(command);
737
1071
  if (paths.length === 0) {
738
1072
  if (command.data != null) {
739
- const content = typeof command.data === "string" ? command.data : JSON.stringify(command.data);
740
- const lineCount = content === "" ? 0 : content.split("\n").length;
741
- const wordCount = content.trim() === "" ? 0 : content.trim().split(/\s+/).length;
742
- const charCount = content.length;
1073
+ let content = pipedText(command.data);
1074
+ if (content !== "" && !content.endsWith("\n")) content += "\n";
1075
+ const { lineCount, wordCount, charCount } = this.count(content);
743
1076
  const parts = [];
744
1077
  if (showAll || showLines) parts.push(String(lineCount).padStart(8));
745
1078
  if (showAll || showWords) parts.push(String(wordCount).padStart(8));
@@ -760,9 +1093,7 @@ var CliWcCommandProcessor = class {
760
1093
  continue;
761
1094
  }
762
1095
  const content = fs.readFile(path) ?? "";
763
- const lineCount = content === "" ? 0 : content.split("\n").length;
764
- const wordCount = content.trim() === "" ? 0 : content.trim().split(/\s+/).length;
765
- const charCount = content.length;
1096
+ const { lineCount, wordCount, charCount } = this.count(content);
766
1097
  totalLines += lineCount;
767
1098
  totalWords += wordCount;
768
1099
  totalChars += charCount;
@@ -785,9 +1116,17 @@ var CliWcCommandProcessor = class {
785
1116
  context.writer.writeln(parts.join(""));
786
1117
  }
787
1118
  }
1119
+ /** Counts like Unix wc: lines are newline characters. */
1120
+ count(content) {
1121
+ return {
1122
+ lineCount: (content.match(/\n/g) ?? []).length,
1123
+ wordCount: content.trim() === "" ? 0 : content.trim().split(/\s+/).length,
1124
+ charCount: content.length
1125
+ };
1126
+ }
788
1127
  parsePaths(command) {
789
1128
  const raw = command.value || "";
790
- return raw.split(/\s+/).filter(Boolean);
1129
+ return splitArgs(raw).filter(Boolean);
791
1130
  }
792
1131
  };
793
1132
  var CliFindCommandProcessor = class {
@@ -801,7 +1140,13 @@ var CliFindCommandProcessor = class {
801
1140
  this.parameters = [
802
1141
  {
803
1142
  name: "name",
804
- description: "Match filename pattern (glob: * and ? supported)",
1143
+ description: "Match filename pattern (glob: * and ? supported), case-sensitive",
1144
+ required: false,
1145
+ type: "string"
1146
+ },
1147
+ {
1148
+ name: "iname",
1149
+ description: "Like --name, but case-insensitive",
805
1150
  required: false,
806
1151
  type: "string"
807
1152
  },
@@ -813,20 +1158,42 @@ var CliFindCommandProcessor = class {
813
1158
  },
814
1159
  {
815
1160
  name: "maxdepth",
816
- description: "Maximum directory depth to search",
1161
+ description: "Maximum depth: 0 is only the start path, 1 its direct children",
817
1162
  required: false,
818
1163
  type: "number"
819
1164
  }
820
1165
  ];
821
1166
  }
1167
+ writeDescription(context) {
1168
+ const t = context.translator;
1169
+ writeWrapped(
1170
+ context,
1171
+ t.t("cli.find.help.list_files_directories_under", "List files and directories under a path (the current directory by default), searching all subdirectories. The start path itself is listed first. Filter by name with a glob pattern (* and ?; --name is case-sensitive, --iname is not) or by type.")
1172
+ );
1173
+ context.writer.writeln();
1174
+ writeUsage(context, [
1175
+ ["find [path]", t.t("cli.find.help.list_everything_under_path", "List everything under a path")],
1176
+ ["find [path] --name <pattern>", t.t("cli.find.help.match_names_against_glob", "Match names against a glob")],
1177
+ ["find [path] --iname <pattern>", t.t("cli.find.help.same_ignoring_case", "Same, ignoring case")],
1178
+ ["find [path] --type f|d", t.t("cli.find.help.only_files_f_directories", "Only files (f) or directories (d)")]
1179
+ ]);
1180
+ context.writer.writeln();
1181
+ writeExamples(context, [
1182
+ ["find ~/docs", t.t("cli.find.help.list_everything_docs", "List everything in ~/docs")],
1183
+ ['find . --name "*.txt"', t.t("cli.find.help.find_all_txt_files", "Find all .txt files")],
1184
+ ["find . --type d --maxdepth 1", t.t("cli.find.help.directories_near_top", "Directories near the top")],
1185
+ ['find . --name "*.tmp" | xargs rm', t.t("cli.find.help.delete_all_tmp_files", "Delete all .tmp files")]
1186
+ ]);
1187
+ }
822
1188
  async processCommand(command, context) {
823
- const fs = context.services.get(
1189
+ const fs = context.services.getRequired(
824
1190
  IFileSystemService_TOKEN
825
1191
  );
826
1192
  const searchPath = this.parseSearchPath(command) || fs.getCurrentDirectory();
827
- const namePattern = command.args["name"] || null;
828
1193
  const typeFilter = command.args["type"] || null;
829
- const maxDepth = command.args["maxdepth"] ? parseInt(command.args["maxdepth"], 10) : Infinity;
1194
+ const maxDepthArg = command.args["maxdepth"];
1195
+ const parsedDepth = parseInt(String(maxDepthArg ?? ""), 10);
1196
+ const maxDepth = maxDepthArg != null && maxDepthArg !== true && !isNaN(parsedDepth) ? parsedDepth : Infinity;
830
1197
  try {
831
1198
  const node = fs.getNode(searchPath);
832
1199
  if (!node) {
@@ -835,24 +1202,25 @@ var CliFindCommandProcessor = class {
835
1202
  );
836
1203
  return;
837
1204
  }
838
- if (node.type !== "directory") {
839
- context.writer.writeError(
840
- `find: '${searchPath}': Not a directory`
841
- );
842
- return;
843
- }
844
1205
  const resolvedBase = fs.resolvePath(searchPath);
845
- const nameRegex = namePattern ? this.globToRegex(namePattern) : null;
1206
+ const nameRegex = this.nameRegex(command);
846
1207
  const results = [];
847
- this.walk(
848
- node,
849
- resolvedBase,
850
- 0,
851
- maxDepth,
852
- nameRegex,
853
- typeFilter,
854
- results
855
- );
1208
+ this.fs = fs;
1209
+ const baseName = resolvedBase.split("/").filter(Boolean).pop() ?? "/";
1210
+ if (this.matches(baseName, node.type, nameRegex, typeFilter)) {
1211
+ results.push(resolvedBase);
1212
+ }
1213
+ if (node.type === "directory" && maxDepth > 0) {
1214
+ this.walk(
1215
+ node,
1216
+ resolvedBase,
1217
+ 1,
1218
+ maxDepth,
1219
+ nameRegex,
1220
+ typeFilter,
1221
+ results
1222
+ );
1223
+ }
856
1224
  for (const r of results) {
857
1225
  context.writer.writeln(r);
858
1226
  }
@@ -860,31 +1228,41 @@ var CliFindCommandProcessor = class {
860
1228
  context.writer.writeError(`find: ${e.message}`);
861
1229
  }
862
1230
  }
1231
+ /** `--name` matches case-sensitively, `--iname` ignoring case. */
1232
+ nameRegex(command) {
1233
+ const name = command.args["name"];
1234
+ if (name != null && name !== true) {
1235
+ return this.globToRegex(String(name), false);
1236
+ }
1237
+ const iname = command.args["iname"];
1238
+ if (iname != null && iname !== true) {
1239
+ return this.globToRegex(String(iname), true);
1240
+ }
1241
+ return null;
1242
+ }
1243
+ matches(name, type, nameRegex, typeFilter) {
1244
+ if (nameRegex && !nameRegex.test(name)) return false;
1245
+ if (typeFilter === "f" && type !== "file") return false;
1246
+ if (typeFilter === "d" && type !== "directory") return false;
1247
+ return true;
1248
+ }
863
1249
  parseSearchPath(command) {
864
1250
  const raw = command.value || "";
865
- const tokens = raw.split(/\s+/).filter(Boolean);
1251
+ const tokens = splitArgs(raw).filter(Boolean);
866
1252
  return tokens.length > 0 ? tokens[0] : null;
867
1253
  }
868
- globToRegex(pattern) {
1254
+ globToRegex(pattern, ignoreCase) {
869
1255
  const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&");
870
1256
  const regexStr = escaped.replace(/\*/g, ".*").replace(/\?/g, ".");
871
- return new RegExp(`^${regexStr}$`, "i");
1257
+ return new RegExp(`^${regexStr}$`, ignoreCase ? "i" : "");
872
1258
  }
1259
+ /** Lists the children of `node`, which are at `depth` (1 = direct children). */
873
1260
  walk(node, currentPath, depth, maxDepth, nameRegex, typeFilter, results) {
874
- if (!node.children) return;
875
- for (const child of node.children) {
1261
+ const children = this.fs ? readableChildren(this.fs, currentPath) : node.children;
1262
+ if (!children) return;
1263
+ for (const child of children) {
876
1264
  const childPath = currentPath === "/" ? `/${child.name}` : `${currentPath}/${child.name}`;
877
- let matches = true;
878
- if (nameRegex && !nameRegex.test(child.name)) {
879
- matches = false;
880
- }
881
- if (typeFilter === "f" && child.type !== "file") {
882
- matches = false;
883
- }
884
- if (typeFilter === "d" && child.type !== "directory") {
885
- matches = false;
886
- }
887
- if (matches) {
1265
+ if (this.matches(child.name, child.type, nameRegex, typeFilter)) {
888
1266
  results.push(childPath);
889
1267
  }
890
1268
  if (child.type === "directory" && depth < maxDepth) {
@@ -938,6 +1316,13 @@ var CliGrepCommandProcessor = class {
938
1316
  required: false,
939
1317
  type: "boolean"
940
1318
  },
1319
+ {
1320
+ name: "quiet",
1321
+ aliases: ["q"],
1322
+ description: "Print nothing; only the exit status tells whether a line matched",
1323
+ required: false,
1324
+ type: "boolean"
1325
+ },
941
1326
  {
942
1327
  name: "files-with-matches",
943
1328
  aliases: ["l"],
@@ -951,11 +1336,52 @@ var CliGrepCommandProcessor = class {
951
1336
  description: "Select non-matching lines",
952
1337
  required: false,
953
1338
  type: "boolean"
1339
+ },
1340
+ {
1341
+ name: "word-regexp",
1342
+ aliases: ["w"],
1343
+ description: "Match only whole words",
1344
+ required: false,
1345
+ type: "boolean"
1346
+ },
1347
+ {
1348
+ name: "only-matching",
1349
+ aliases: ["o"],
1350
+ description: "Print only the matched parts, one per line",
1351
+ required: false,
1352
+ type: "boolean"
1353
+ },
1354
+ {
1355
+ name: "extended-regexp",
1356
+ aliases: ["E"],
1357
+ description: "Extended regular expressions (patterns always are)",
1358
+ required: false,
1359
+ type: "boolean"
954
1360
  }
955
1361
  ];
956
1362
  }
1363
+ writeDescription(context) {
1364
+ const t = context.translator;
1365
+ writeWrapped(
1366
+ context,
1367
+ t.t("cli.grep.help.print_lines_match_pattern", "Print the lines that match a pattern. The pattern is a regular expression, matches are highlighted, and the input can be files, directories (with -r) or piped text.")
1368
+ );
1369
+ context.writer.writeln();
1370
+ writeUsage(context, [
1371
+ ["grep <pattern> <file...>", t.t("cli.grep.help.search_one_more_files", "Search one or more files")],
1372
+ ["grep -r <pattern> <dir>", t.t("cli.grep.help.search_every_file_under", "Search every file under a directory")],
1373
+ ["<command> | grep <pattern>", t.t("cli.grep.help.filter_piped_input", "Filter piped input")]
1374
+ ]);
1375
+ context.writer.writeln();
1376
+ writeExamples(context, [
1377
+ ["grep -i error app.log", t.t("cli.grep.help.case_insensitive_search", "Case-insensitive search")],
1378
+ ["grep -r -n TODO src", t.t("cli.grep.help.find_todos_line_numbers", "Find TODOs with line numbers")],
1379
+ ["grep -v ^# config.txt", t.t("cli.grep.help.hide_comment_lines", "Hide comment lines")],
1380
+ ["cat app.log | grep -c ERROR", t.t("cli.grep.help.count_matching_lines", "Count matching lines")]
1381
+ ]);
1382
+ }
957
1383
  async processCommand(command, context) {
958
- const fs = context.services.get(
1384
+ const fs = context.services.getRequired(
959
1385
  IFileSystemService_TOKEN
960
1386
  );
961
1387
  const ignoreCase = command.args["ignore-case"] || command.args["i"] || false;
@@ -964,51 +1390,56 @@ var CliGrepCommandProcessor = class {
964
1390
  const countOnly = command.args["count"] || command.args["c"] || false;
965
1391
  const filesOnly = command.args["files-with-matches"] || command.args["l"] || false;
966
1392
  const invert = command.args["invert-match"] || command.args["v"] || false;
1393
+ const wordRegexp = command.args["word-regexp"] || command.args["w"] || false;
1394
+ const onlyMatching = command.args["only-matching"] || command.args["o"] || false;
1395
+ const quiet = command.args["quiet"] || command.args["q"] || false;
967
1396
  const { pattern, paths } = this.parseArgs(command);
968
1397
  if (!pattern) {
969
1398
  context.writer.writeError(
970
1399
  "grep: missing pattern. Usage: grep [options] <pattern> <file>"
971
1400
  );
1401
+ context.process.exit(2, { silent: true });
1402
+ return;
1403
+ }
1404
+ let regex;
1405
+ try {
1406
+ const source = wordRegexp ? `(?<!\\w)(?:${pattern})(?!\\w)` : pattern;
1407
+ regex = new RegExp(source, ignoreCase ? "gi" : "g");
1408
+ } catch {
1409
+ context.writer.writeError(`grep: invalid pattern '${pattern}'`);
1410
+ context.process.exit(2, { silent: true });
972
1411
  return;
973
1412
  }
974
1413
  if (paths.length === 0) {
975
1414
  if (command.data != null) {
976
- let regex2;
977
- try {
978
- regex2 = new RegExp(pattern, ignoreCase ? "gi" : "g");
979
- } catch {
980
- context.writer.writeError(`grep: invalid pattern '${pattern}'`);
981
- return;
982
- }
983
1415
  const content = typeof command.data === "string" ? command.data : JSON.stringify(command.data);
984
- this.grepContent(content, null, regex2, {
1416
+ const found2 = this.grepContent(content, null, regex, {
985
1417
  ignoreCase,
986
1418
  showLineNum,
987
1419
  countOnly,
988
1420
  filesOnly,
989
- invert
1421
+ invert,
1422
+ onlyMatching,
1423
+ quiet
990
1424
  }, context, false);
1425
+ this.exitStatus(context, found2, false);
991
1426
  return;
992
1427
  }
993
1428
  context.writer.writeError(
994
1429
  "grep: missing file operand. Usage: grep [options] <pattern> <file>"
995
1430
  );
996
- return;
997
- }
998
- let regex;
999
- try {
1000
- regex = new RegExp(pattern, ignoreCase ? "gi" : "g");
1001
- } catch {
1002
- context.writer.writeError(`grep: invalid pattern '${pattern}'`);
1431
+ context.process.exit(2, { silent: true });
1003
1432
  return;
1004
1433
  }
1005
1434
  const filePaths = [];
1435
+ let failed = false;
1006
1436
  for (const p of paths) {
1007
1437
  try {
1008
1438
  if (!fs.exists(p)) {
1009
1439
  context.writer.writeError(
1010
1440
  `grep: ${p}: No such file or directory`
1011
1441
  );
1442
+ failed = true;
1012
1443
  continue;
1013
1444
  }
1014
1445
  if (fs.isDirectory(p)) {
@@ -1016,6 +1447,7 @@ var CliGrepCommandProcessor = class {
1016
1447
  context.writer.writeError(
1017
1448
  `grep: ${p}: Is a directory`
1018
1449
  );
1450
+ failed = true;
1019
1451
  continue;
1020
1452
  }
1021
1453
  this.collectFiles(fs, fs.resolvePath(p), fs.getNode(p), filePaths);
@@ -1024,26 +1456,40 @@ var CliGrepCommandProcessor = class {
1024
1456
  }
1025
1457
  } catch (e) {
1026
1458
  context.writer.writeError(`grep: ${e.message}`);
1459
+ failed = true;
1027
1460
  }
1028
1461
  }
1029
1462
  const multiFile = filePaths.length > 1;
1463
+ let found = 0;
1030
1464
  for (const filePath of filePaths) {
1031
1465
  try {
1032
1466
  const content = fs.readFile(filePath) ?? "";
1033
- this.grepContent(content, filePath, regex, {
1467
+ found += this.grepContent(content, filePath, regex, {
1034
1468
  ignoreCase,
1035
1469
  showLineNum,
1036
1470
  countOnly,
1037
1471
  filesOnly,
1038
- invert
1472
+ invert,
1473
+ onlyMatching,
1474
+ quiet
1039
1475
  }, context, multiFile);
1040
1476
  } catch (e) {
1041
1477
  context.writer.writeError(`grep: ${filePath}: ${e.message}`);
1478
+ failed = true;
1042
1479
  }
1043
1480
  }
1481
+ this.exitStatus(context, found, failed);
1482
+ }
1483
+ /**
1484
+ * As Unix grep: 0 when a line matched, 1 when none did, 2 after an
1485
+ * error (so `if grep -q ...` and `grep x f && ...` work).
1486
+ */
1487
+ exitStatus(context, found, failed) {
1488
+ if (found > 0) return;
1489
+ context.process.exit(failed ? 2 : 1, { silent: true });
1044
1490
  }
1045
1491
  grepContent(content, filePath, regex, options, context, multiFile) {
1046
- const lines = content.split("\n");
1492
+ const lines = splitLines(content);
1047
1493
  let matchCount = 0;
1048
1494
  const matchingLines = [];
1049
1495
  for (let lineIdx = 0; lineIdx < lines.length; lineIdx++) {
@@ -1056,6 +1502,7 @@ var CliGrepCommandProcessor = class {
1056
1502
  matchingLines.push({ num: lineIdx + 1, text: line });
1057
1503
  }
1058
1504
  }
1505
+ if (options.quiet) return matchCount;
1059
1506
  if (options.filesOnly) {
1060
1507
  if (matchCount > 0 && filePath) {
1061
1508
  context.writer.writeln(filePath);
@@ -1074,6 +1521,18 @@ var CliGrepCommandProcessor = class {
1074
1521
  parts.push(context.writer.wrapInColor(String(m.num), CliForegroundColor.Green));
1075
1522
  parts.push(":");
1076
1523
  }
1524
+ if (options.onlyMatching && !options.invert) {
1525
+ regex.lastIndex = 0;
1526
+ const prefix = parts.join("");
1527
+ for (const match of m.text.match(regex) ?? []) {
1528
+ if (match !== "") {
1529
+ context.writer.writeln(
1530
+ prefix + context.writer.wrapInColor(match, CliForegroundColor.Red)
1531
+ );
1532
+ }
1533
+ }
1534
+ continue;
1535
+ }
1077
1536
  if (!options.invert) {
1078
1537
  regex.lastIndex = 0;
1079
1538
  const highlighted = m.text.replace(
@@ -1087,10 +1546,11 @@ var CliGrepCommandProcessor = class {
1087
1546
  context.writer.writeln(parts.join(""));
1088
1547
  }
1089
1548
  }
1549
+ return matchCount;
1090
1550
  }
1091
1551
  parseArgs(command) {
1092
1552
  const raw = command.value || "";
1093
- const tokens = raw.split(/\s+/).filter(Boolean);
1553
+ const tokens = splitArgs(raw).filter(Boolean);
1094
1554
  if (tokens.length === 0) {
1095
1555
  return { pattern: null, paths: [] };
1096
1556
  }
@@ -1103,8 +1563,9 @@ var CliGrepCommandProcessor = class {
1103
1563
  };
1104
1564
  }
1105
1565
  collectFiles(fs, basePath, node, result) {
1106
- if (!node.children) return;
1107
- for (const child of node.children) {
1566
+ const children = readableChildren(fs, basePath);
1567
+ if (!children) return;
1568
+ for (const child of children) {
1108
1569
  const childPath = basePath === "/" ? `/${child.name}` : `${basePath}/${child.name}`;
1109
1570
  if (child.type === "file") {
1110
1571
  result.push(childPath);
@@ -1124,8 +1585,25 @@ var CliTacCommandProcessor = class {
1124
1585
  this.valueRequired = true;
1125
1586
  this.metadata = { icon: "\u{1F504}", module: "file management" };
1126
1587
  }
1588
+ writeDescription(context) {
1589
+ const t = context.translator;
1590
+ writeWrapped(
1591
+ context,
1592
+ t.t("cli.tac.help.print_file_lines_reverse", "Print a file with its lines in reverse order, last line first. Works on piped input too.")
1593
+ );
1594
+ context.writer.writeln();
1595
+ writeUsage(context, [
1596
+ ["tac <file>", t.t("cli.tac.help.print_file_bottom_top", "Print a file bottom to top")],
1597
+ ["<command> | tac", t.t("cli.tac.help.reverse_piped_input", "Reverse piped input")]
1598
+ ]);
1599
+ context.writer.writeln();
1600
+ writeExamples(context, [
1601
+ ["tac app.log", t.t("cli.tac.help.newest_log_lines_first", "Newest log lines first")],
1602
+ ["cat list.txt | tac", t.t("cli.tac.help.reverse_list", "Reverse a list")]
1603
+ ]);
1604
+ }
1127
1605
  async processCommand(command, context) {
1128
- const fs = context.services.get(
1606
+ const fs = context.services.getRequired(
1129
1607
  IFileSystemService_TOKEN
1130
1608
  );
1131
1609
  const paths = this.parsePaths(command);
@@ -1169,7 +1647,7 @@ var CliTacCommandProcessor = class {
1169
1647
  }
1170
1648
  parsePaths(command) {
1171
1649
  const raw = command.value || "";
1172
- return raw.split(/\s+/).filter(Boolean);
1650
+ return splitArgs(raw).filter(Boolean);
1173
1651
  }
1174
1652
  };
1175
1653
  var CliBasenameCommandProcessor = class {
@@ -1182,6 +1660,24 @@ var CliBasenameCommandProcessor = class {
1182
1660
  this.valueRequired = true;
1183
1661
  this.metadata = { icon: "\u{1F4DB}", module: "file management" };
1184
1662
  }
1663
+ writeDescription(context) {
1664
+ const t = context.translator;
1665
+ writeWrapped(
1666
+ context,
1667
+ t.t("cli.basename.help.print_last_part_path", "Print the last part of a path, the file or directory name, without the directories in front. If a suffix is given, it is removed from the end too.")
1668
+ );
1669
+ context.writer.writeln();
1670
+ writeUsage(context, [
1671
+ ["basename <path>", t.t("cli.basename.help.name_without_directories", "Name without the directories")],
1672
+ ["basename <path> <suffix>", t.t("cli.basename.help.also_remove_trailing_suffix", "Also remove a trailing suffix")]
1673
+ ]);
1674
+ context.writer.writeln();
1675
+ writeExamples(context, [
1676
+ ["basename /home/user/notes.txt", t.t("cli.basename.help.prints_notes_txt", "Prints notes.txt")],
1677
+ ["basename /home/user/notes.txt .txt", t.t("cli.basename.help.prints_notes", "Prints notes")],
1678
+ ["basename /home/user/", t.t("cli.basename.help.prints_user", "Prints user")]
1679
+ ]);
1680
+ }
1185
1681
  async processCommand(command, context) {
1186
1682
  const tokens = this.parseTokens(command);
1187
1683
  if (tokens.length === 0) {
@@ -1207,7 +1703,7 @@ var CliBasenameCommandProcessor = class {
1207
1703
  }
1208
1704
  parseTokens(command) {
1209
1705
  const raw = command.value || "";
1210
- return raw.split(/\s+/).filter(Boolean);
1706
+ return splitArgs(raw).filter(Boolean);
1211
1707
  }
1212
1708
  };
1213
1709
  var CliDirnameCommandProcessor = class {
@@ -1220,6 +1716,23 @@ var CliDirnameCommandProcessor = class {
1220
1716
  this.valueRequired = true;
1221
1717
  this.metadata = { icon: "\u{1F4C2}", module: "file management" };
1222
1718
  }
1719
+ writeDescription(context) {
1720
+ const t = context.translator;
1721
+ writeWrapped(
1722
+ context,
1723
+ t.t("cli.dirname.help.print_directory_part_path", "Print the directory part of a path, everything before the last /. A bare name with no / gives . (the current directory).")
1724
+ );
1725
+ context.writer.writeln();
1726
+ writeUsage(context, [
1727
+ ["dirname <path>", t.t("cli.dirname.help.path_without_last_part", "Path without its last part")]
1728
+ ]);
1729
+ context.writer.writeln();
1730
+ writeExamples(context, [
1731
+ ["dirname /home/user/notes.txt", t.t("cli.dirname.help.prints_home_user", "Prints /home/user")],
1732
+ ["dirname notes.txt", t.t("cli.dirname.help.prints", "Prints .")],
1733
+ ["dirname /home", t.t("cli.dirname.help.prints_2", "Prints /")]
1734
+ ]);
1735
+ }
1223
1736
  async processCommand(command, context) {
1224
1737
  const tokens = this.parseTokens(command);
1225
1738
  if (tokens.length === 0) {
@@ -1246,7 +1759,7 @@ var CliDirnameCommandProcessor = class {
1246
1759
  }
1247
1760
  parseTokens(command) {
1248
1761
  const raw = command.value || "";
1249
- return raw.split(/\s+/).filter(Boolean);
1762
+ return splitArgs(raw).filter(Boolean);
1250
1763
  }
1251
1764
  };
1252
1765
  var CliSortCommandProcessor = class {
@@ -1296,117 +1809,97 @@ var CliSortCommandProcessor = class {
1296
1809
  }
1297
1810
  ];
1298
1811
  }
1812
+ writeDescription(context) {
1813
+ const t = context.translator;
1814
+ writeWrapped(
1815
+ context,
1816
+ t.t("cli.sort.help.sort_lines_file_piped", "Sort the lines of a file or piped input, alphabetically by default. Sort numerically, in reverse, by a field, or drop duplicates. Several files are sorted together into one list.")
1817
+ );
1818
+ context.writer.writeln();
1819
+ writeUsage(context, [
1820
+ ["sort <file>", t.t("cli.sort.help.sort_lines_alphabetically", "Sort lines alphabetically")],
1821
+ [
1822
+ "sort -k <n> -t <sep> <file>",
1823
+ t.t("cli.sort.help.sort_field_split", "Sort by field <n>, split on <sep>")
1824
+ ],
1825
+ ["<command> | sort", t.t("cli.sort.help.sort_piped_input", "Sort piped input")]
1826
+ ]);
1827
+ context.writer.writeln();
1828
+ writeExamples(context, [
1829
+ ["sort names.txt", t.t("cli.sort.help.z", "A to Z")],
1830
+ ["sort -n -r scores.txt", t.t("cli.sort.help.largest_number_first", "Largest number first")],
1831
+ ["sort -t , -k 2 data.csv", t.t("cli.sort.help.sort_csv_second_column", "Sort a CSV by its second column")],
1832
+ ["cat words.txt | sort -u", t.t("cli.sort.help.sorted_without_duplicates", "Sorted, without duplicates")]
1833
+ ]);
1834
+ }
1299
1835
  async processCommand(command, context) {
1300
- const fs = context.services.get(
1836
+ const fs = context.services.getRequired(
1301
1837
  IFileSystemService_TOKEN
1302
1838
  );
1303
1839
  const reverse = !!command.args["r"] || !!command.args["reverse"];
1304
1840
  const numeric = !!command.args["n"] || !!command.args["numeric-sort"];
1305
1841
  const unique = !!command.args["u"] || !!command.args["unique"];
1306
- const keyField = command.args["k"] || command.args["key"];
1307
- const delimiter = command.args["t"] || command.args["field-separator"];
1842
+ const keyField = command.args["k"] ?? command.args["key"];
1843
+ const delimiter = command.args["t"] ?? command.args["field-separator"];
1308
1844
  const paths = this.parsePaths(command);
1845
+ let lines = [];
1309
1846
  if (paths.length === 0) {
1310
- if (command.data != null) {
1311
- const content = typeof command.data === "string" ? command.data : JSON.stringify(command.data);
1312
- let lines = content.split("\n");
1313
- if (lines.length > 0 && lines[lines.length - 1] === "") {
1314
- lines.pop();
1315
- }
1316
- const keyIndex = keyField ? parseInt(keyField, 10) - 1 : void 0;
1317
- lines.sort((a, b) => {
1318
- let va = a;
1319
- let vb = b;
1320
- if (keyIndex !== void 0 && keyIndex >= 0) {
1321
- const sepRegex = delimiter ? new RegExp(
1322
- delimiter.replace(
1323
- /[.*+?^${}()|[\]\\]/g,
1324
- "\\$&"
1325
- )
1326
- ) : /\s+/;
1327
- const fieldsA = a.split(sepRegex);
1328
- const fieldsB = b.split(sepRegex);
1329
- va = fieldsA[keyIndex] || "";
1330
- vb = fieldsB[keyIndex] || "";
1331
- }
1332
- if (numeric) {
1333
- const na = parseFloat(va) || 0;
1334
- const nb = parseFloat(vb) || 0;
1335
- return na - nb;
1336
- }
1337
- return va.localeCompare(vb);
1338
- });
1339
- if (unique) {
1340
- lines = lines.filter(
1341
- (line, i, arr) => i === 0 || line !== arr[i - 1]
1342
- );
1343
- }
1344
- if (reverse) {
1345
- lines.reverse();
1346
- }
1347
- context.writer.writeln(lines.join("\n"));
1847
+ if (command.data == null) {
1848
+ context.writer.writeError("sort: missing file operand");
1348
1849
  return;
1349
1850
  }
1350
- context.writer.writeError("sort: missing file operand");
1351
- return;
1352
- }
1353
- for (const path of paths) {
1354
- try {
1355
- if (fs.isDirectory(path)) {
1356
- context.writer.writeError(`sort: ${path}: Is a directory`);
1357
- continue;
1358
- }
1359
- const content = fs.readFile(path);
1360
- if (content === null) {
1361
- context.writer.writeError(
1362
- `sort: ${path}: No such file or directory`
1363
- );
1364
- continue;
1365
- }
1366
- let lines = content.split("\n");
1367
- if (lines.length > 0 && lines[lines.length - 1] === "") {
1368
- lines.pop();
1369
- }
1370
- const keyIndex = keyField ? parseInt(keyField, 10) - 1 : void 0;
1371
- lines.sort((a, b) => {
1372
- let va = a;
1373
- let vb = b;
1374
- if (keyIndex !== void 0 && keyIndex >= 0) {
1375
- const sepRegex = delimiter ? new RegExp(
1376
- delimiter.replace(
1377
- /[.*+?^${}()|[\]\\]/g,
1378
- "\\$&"
1379
- )
1380
- ) : /\s+/;
1381
- const fieldsA = a.split(sepRegex);
1382
- const fieldsB = b.split(sepRegex);
1383
- va = fieldsA[keyIndex] || "";
1384
- vb = fieldsB[keyIndex] || "";
1851
+ lines = splitLines(pipedText(command.data));
1852
+ } else {
1853
+ for (const path of paths) {
1854
+ try {
1855
+ if (fs.isDirectory(path)) {
1856
+ context.writer.writeError(
1857
+ `sort: ${path}: Is a directory`
1858
+ );
1859
+ continue;
1385
1860
  }
1386
- if (numeric) {
1387
- const na = parseFloat(va) || 0;
1388
- const nb = parseFloat(vb) || 0;
1389
- return na - nb;
1861
+ const content = fs.readFile(path);
1862
+ if (content === null) {
1863
+ context.writer.writeError(
1864
+ `sort: ${path}: No such file or directory`
1865
+ );
1866
+ continue;
1390
1867
  }
1391
- return va.localeCompare(vb);
1392
- });
1393
- if (unique) {
1394
- lines = lines.filter(
1395
- (line, i, arr) => i === 0 || line !== arr[i - 1]
1396
- );
1397
- }
1398
- if (reverse) {
1399
- lines.reverse();
1868
+ lines.push(...splitLines(content));
1869
+ } catch (e) {
1870
+ context.writer.writeError(`sort: ${e.message}`);
1400
1871
  }
1401
- context.writer.writeln(lines.join("\n"));
1402
- } catch (e) {
1403
- context.writer.writeError(`sort: ${e.message}`);
1404
1872
  }
1405
1873
  }
1874
+ const keyIndex = keyField != null && keyField !== true ? parseInt(String(keyField), 10) - 1 : void 0;
1875
+ const sepRegex = delimiter != null && delimiter !== true ? new RegExp(
1876
+ String(delimiter).replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
1877
+ ) : /\s+/;
1878
+ const keyOf = (line) => keyIndex !== void 0 && keyIndex >= 0 ? line.split(sepRegex)[keyIndex] || "" : line;
1879
+ const compare = (a, b) => {
1880
+ const va = keyOf(a);
1881
+ const vb = keyOf(b);
1882
+ if (numeric) {
1883
+ return (parseFloat(va) || 0) - (parseFloat(vb) || 0);
1884
+ }
1885
+ return va.localeCompare(vb);
1886
+ };
1887
+ lines.sort(compare);
1888
+ if (unique) {
1889
+ lines = lines.filter(
1890
+ (line, i, arr) => i === 0 || compare(line, arr[i - 1]) !== 0
1891
+ );
1892
+ }
1893
+ if (reverse) {
1894
+ lines.reverse();
1895
+ }
1896
+ if (lines.length > 0) {
1897
+ context.writer.writeln(lines.join("\n"));
1898
+ }
1406
1899
  }
1407
1900
  parsePaths(command) {
1408
1901
  const raw = command.value || "";
1409
- return raw.split(/\s+/).filter(Boolean);
1902
+ return splitArgs(raw).filter(Boolean);
1410
1903
  }
1411
1904
  };
1412
1905
  var CliUniqCommandProcessor = class {
@@ -1449,8 +1942,26 @@ var CliUniqCommandProcessor = class {
1449
1942
  }
1450
1943
  ];
1451
1944
  }
1945
+ writeDescription(context) {
1946
+ const t = context.translator;
1947
+ writeWrapped(
1948
+ context,
1949
+ t.t("cli.uniq.help.collapse_repeated_lines_next", "Collapse repeated lines that are next to each other into one. Only adjacent duplicates are merged, so sort the input first to catch them all.")
1950
+ );
1951
+ context.writer.writeln();
1952
+ writeUsage(context, [
1953
+ ["uniq <file>", t.t("cli.uniq.help.drop_adjacent_duplicate_lines", "Drop adjacent duplicate lines")],
1954
+ ["<command> | uniq", t.t("cli.uniq.help.filter_piped_input", "Filter piped input")]
1955
+ ]);
1956
+ context.writer.writeln();
1957
+ writeExamples(context, [
1958
+ ["uniq log.txt", t.t("cli.uniq.help.collapse_repeated_lines", "Collapse repeated lines")],
1959
+ ["sort words.txt | uniq -c", t.t("cli.uniq.help.count_how_often_each", "Count how often each line appears")],
1960
+ ["sort words.txt | uniq -d", t.t("cli.uniq.help.show_only_duplicated_lines", "Show only the duplicated lines")]
1961
+ ]);
1962
+ }
1452
1963
  async processCommand(command, context) {
1453
- const fs = context.services.get(
1964
+ const fs = context.services.getRequired(
1454
1965
  IFileSystemService_TOKEN
1455
1966
  );
1456
1967
  const showCount = !!command.args["c"] || !!command.args["count"];
@@ -1461,7 +1972,7 @@ var CliUniqCommandProcessor = class {
1461
1972
  if (paths.length === 0) {
1462
1973
  if (command.data != null) {
1463
1974
  const content = typeof command.data === "string" ? command.data : JSON.stringify(command.data);
1464
- let lines = content.split("\n");
1975
+ const lines = content.split("\n");
1465
1976
  if (lines.length > 0 && lines[lines.length - 1] === "") {
1466
1977
  lines.pop();
1467
1978
  }
@@ -1509,7 +2020,7 @@ var CliUniqCommandProcessor = class {
1509
2020
  );
1510
2021
  continue;
1511
2022
  }
1512
- let lines = content.split("\n");
2023
+ const lines = content.split("\n");
1513
2024
  if (lines.length > 0 && lines[lines.length - 1] === "") {
1514
2025
  lines.pop();
1515
2026
  }
@@ -1544,7 +2055,7 @@ var CliUniqCommandProcessor = class {
1544
2055
  }
1545
2056
  parsePaths(command) {
1546
2057
  const raw = command.value || "";
1547
- return raw.split(/\s+/).filter(Boolean);
2058
+ return splitArgs(raw).filter(Boolean);
1548
2059
  }
1549
2060
  };
1550
2061
  var CliCutCommandProcessor = class {
@@ -1580,26 +2091,45 @@ var CliCutCommandProcessor = class {
1580
2091
  }
1581
2092
  ];
1582
2093
  }
2094
+ writeDescription(context) {
2095
+ const t = context.translator;
2096
+ writeWrapped(
2097
+ context,
2098
+ t.t("cli.cut.help.print_selected_parts_each", "Print selected parts of each line: fields split by a delimiter (-f, tab by default) or character positions (-c). A list can be single numbers, ranges or both, such as 1,3 or 2-4 or 3-.")
2099
+ );
2100
+ context.writer.writeln();
2101
+ writeUsage(context, [
2102
+ ["cut -d <sep> -f <list> <file>", t.t("cli.cut.help.select_fields", "Select fields")],
2103
+ ["cut -c <list> <file>", t.t("cli.cut.help.select_character_positions", "Select character positions")],
2104
+ ["<command> | cut -f <list>", t.t("cli.cut.help.cut_piped_input", "Cut piped input")]
2105
+ ]);
2106
+ context.writer.writeln();
2107
+ writeExamples(context, [
2108
+ ["cut -d , -f 1,3 data.csv", t.t("cli.cut.help.first_third_csv_column", "First and third CSV column")],
2109
+ ["cut -c 1-10 notes.txt", t.t("cli.cut.help.first_10_characters_each", "First 10 characters of each line")],
2110
+ ["cat users.txt | cut -d : -f 1-2", t.t("cli.cut.help.first_two_fields_split", "First two fields split on :")]
2111
+ ]);
2112
+ }
1583
2113
  async processCommand(command, context) {
1584
- const fs = context.services.get(
2114
+ const fs = context.services.getRequired(
1585
2115
  IFileSystemService_TOKEN
1586
2116
  );
1587
2117
  const delimiter = command.args["d"] || command.args["delimiter"] || " ";
1588
- const fieldsSpec = command.args["f"] || command.args["fields"];
1589
- const charsSpec = command.args["c"] || command.args["characters"];
1590
- if (!fieldsSpec && !charsSpec) {
2118
+ const fieldsSpec = command.args["f"] ?? command.args["fields"];
2119
+ const charsSpec = command.args["c"] ?? command.args["characters"];
2120
+ if ((fieldsSpec === void 0 || fieldsSpec === "") && (charsSpec === void 0 || charsSpec === "")) {
1591
2121
  context.writer.writeError(
1592
2122
  "cut: you must specify either -f (fields) or -c (characters)"
1593
2123
  );
1594
2124
  return;
1595
2125
  }
1596
- const spec = fieldsSpec || charsSpec;
2126
+ const spec = String(fieldsSpec ?? charsSpec);
1597
2127
  const indices = this.parseSpec(spec);
1598
2128
  const paths = this.parsePaths(command);
1599
2129
  if (paths.length === 0) {
1600
2130
  if (command.data != null) {
1601
2131
  const content = typeof command.data === "string" ? command.data : JSON.stringify(command.data);
1602
- let lines = content.split("\n");
2132
+ const lines = content.split("\n");
1603
2133
  if (lines.length > 0 && lines[lines.length - 1] === "") {
1604
2134
  lines.pop();
1605
2135
  }
@@ -1641,7 +2171,7 @@ var CliCutCommandProcessor = class {
1641
2171
  );
1642
2172
  continue;
1643
2173
  }
1644
- let lines = content.split("\n");
2174
+ const lines = content.split("\n");
1645
2175
  if (lines.length > 0 && lines[lines.length - 1] === "") {
1646
2176
  lines.pop();
1647
2177
  }
@@ -1701,7 +2231,7 @@ var CliCutCommandProcessor = class {
1701
2231
  }
1702
2232
  parsePaths(command) {
1703
2233
  const raw = command.value || "";
1704
- return raw.split(/\s+/).filter(Boolean);
2234
+ return splitArgs(raw).filter(Boolean);
1705
2235
  }
1706
2236
  };
1707
2237
  var CliPasteCommandProcessor = class {
@@ -1730,23 +2260,47 @@ var CliPasteCommandProcessor = class {
1730
2260
  }
1731
2261
  ];
1732
2262
  }
2263
+ writeDescription(context) {
2264
+ const t = context.translator;
2265
+ writeWrapped(
2266
+ context,
2267
+ t.t("cli.paste.help.join_files_side_side", "Join files side by side: line 1 of each file goes on one output line, separated by a tab, then line 2, and so on. With -s, each file is joined onto a single line instead. Piped input is read as the file - (or as the only input when no file is named); -d takes a list of delimiters used in turn.")
2268
+ );
2269
+ context.writer.writeln();
2270
+ writeUsage(context, [
2271
+ ["paste <file1> <file2...>", t.t("cli.paste.help.merge_files_line_line", "Merge files line by line")],
2272
+ ["paste -s <file>", t.t("cli.paste.help.join_all_lines_file", "Join all lines of a file into one")],
2273
+ ["<command> | paste - <file>", t.t("cli.paste.help.merge_piped_input_file", "Merge piped input with a file")]
2274
+ ]);
2275
+ context.writer.writeln();
2276
+ writeExamples(context, [
2277
+ ["paste names.txt ages.txt", t.t("cli.paste.help.two_columns_tab_separated", "Two columns, tab separated")],
2278
+ ["paste -d , a.txt b.txt", t.t("cli.paste.help.merge_comma_separated", "Merge as comma-separated")],
2279
+ ["paste -s -d , list.txt", t.t("cli.paste.help.turn_list_one_csv", "Turn a list into one CSV line")]
2280
+ ]);
2281
+ }
1733
2282
  async processCommand(command, context) {
1734
- const fs = context.services.get(
2283
+ const fs = context.services.getRequired(
1735
2284
  IFileSystemService_TOKEN
1736
2285
  );
1737
- const delimiter = command.args["d"] || command.args["delimiters"] || " ";
2286
+ const delimiterArg = command.args["d"] ?? command.args["delimiters"] ?? " ";
2287
+ const delimiters = this.parseDelimiters(String(delimiterArg));
1738
2288
  const serial = !!command.args["s"] || !!command.args["serial"];
1739
2289
  const paths = this.parsePaths(command);
2290
+ const hasStdin = command.data != null;
1740
2291
  if (paths.length === 0) {
1741
- context.writer.writeError("paste: missing file operand");
1742
- return;
1743
- }
1744
- if (!serial && paths.length < 2) {
1745
- context.writer.writeError("paste: missing file operand");
1746
- return;
2292
+ if (!hasStdin) {
2293
+ context.writer.writeError("paste: missing file operand");
2294
+ return;
2295
+ }
2296
+ paths.push("-");
1747
2297
  }
1748
2298
  const fileLines = [];
1749
2299
  for (const path of paths) {
2300
+ if (path === "-" && hasStdin) {
2301
+ fileLines.push(splitLines(pipedText(command.data)));
2302
+ continue;
2303
+ }
1750
2304
  try {
1751
2305
  if (fs.isDirectory(path)) {
1752
2306
  context.writer.writeError(
@@ -1761,20 +2315,18 @@ var CliPasteCommandProcessor = class {
1761
2315
  );
1762
2316
  return;
1763
2317
  }
1764
- let lines = content.split("\n");
1765
- if (lines.length > 0 && lines[lines.length - 1] === "") {
1766
- lines.pop();
1767
- }
1768
- fileLines.push(lines);
2318
+ fileLines.push(splitLines(content));
1769
2319
  } catch (e) {
1770
2320
  context.writer.writeError(`paste: ${e.message}`);
1771
2321
  return;
1772
2322
  }
1773
2323
  }
2324
+ const join = (parts) => parts.reduce(
2325
+ (line, part, i) => i === 0 ? part : line + delimiters[(i - 1) % delimiters.length] + part,
2326
+ ""
2327
+ );
1774
2328
  if (serial) {
1775
- const output = fileLines.map(
1776
- (lines) => lines.join(delimiter)
1777
- );
2329
+ const output = fileLines.map((lines) => join(lines));
1778
2330
  context.writer.writeln(output.join("\n"));
1779
2331
  } else {
1780
2332
  const maxLines = Math.max(
@@ -1782,21 +2334,59 @@ var CliPasteCommandProcessor = class {
1782
2334
  );
1783
2335
  const output = [];
1784
2336
  for (let i = 0; i < maxLines; i++) {
1785
- const parts = fileLines.map((lines) => lines[i] || "");
1786
- output.push(parts.join(delimiter));
2337
+ output.push(join(fileLines.map((lines) => lines[i] ?? "")));
1787
2338
  }
1788
2339
  context.writer.writeln(output.join("\n"));
1789
2340
  }
1790
2341
  }
2342
+ /** `-d` list: each character in turn, with \t, \n, \\ and \0 (empty) escapes. */
2343
+ parseDelimiters(list) {
2344
+ const result = [];
2345
+ for (let i = 0; i < list.length; i++) {
2346
+ if (list[i] === "\\" && i + 1 < list.length) {
2347
+ const next = list[++i];
2348
+ result.push(
2349
+ next === "t" ? " " : next === "n" ? "\n" : next === "0" ? "" : next
2350
+ );
2351
+ } else {
2352
+ result.push(list[i]);
2353
+ }
2354
+ }
2355
+ return result.length > 0 ? result : [" "];
2356
+ }
2357
+ /**
2358
+ * The file operands. The engine drops a lone `-` from the value, so with
2359
+ * piped input they are read from the raw command line to keep it.
2360
+ */
1791
2361
  parsePaths(command) {
1792
- const raw = command.value || "";
1793
- return raw.split(/\s+/).filter(Boolean);
2362
+ const fromValue = splitArgs(command.value || "").filter(Boolean);
2363
+ if (command.data == null || !command.rawCommand) return fromValue;
2364
+ const words = splitArgs(command.rawCommand).slice(1);
2365
+ if (!words.includes("-")) return fromValue;
2366
+ const result = [];
2367
+ for (let i = 0; i < words.length; i++) {
2368
+ const word = words[i];
2369
+ if (word === "-" || !word.startsWith("-")) {
2370
+ result.push(word);
2371
+ } else if (/^(-d|--delimiters)$/.test(word)) {
2372
+ i++;
2373
+ }
2374
+ }
2375
+ return result;
1794
2376
  }
1795
2377
  };
1796
2378
  function expandSet(set) {
1797
2379
  const chars = [];
1798
2380
  let i = 0;
1799
2381
  while (i < set.length) {
2382
+ if (set[i] === "\\" && i + 1 < set.length) {
2383
+ const next = set[i + 1];
2384
+ chars.push(
2385
+ next === "n" ? "\n" : next === "t" ? " " : next === "r" ? "\r" : next
2386
+ );
2387
+ i += 2;
2388
+ continue;
2389
+ }
1800
2390
  if (set[i] === "[" && set[i + 1] === ":") {
1801
2391
  const end = set.indexOf(":]", i + 2);
1802
2392
  if (end !== -1) {
@@ -1806,7 +2396,7 @@ function expandSet(set) {
1806
2396
  continue;
1807
2397
  }
1808
2398
  }
1809
- if (i + 2 < set.length && set[i + 1] === "-" && set[i + 2] !== void 0) {
2399
+ if (i + 2 < set.length && set[i + 1] === "-" && set[i + 2] !== "\\") {
1810
2400
  const start = set.charCodeAt(i);
1811
2401
  const finish = set.charCodeAt(i + 2);
1812
2402
  if (start <= finish) {
@@ -1876,7 +2466,7 @@ function parseTokens(raw) {
1876
2466
  var CliTrCommandProcessor = class {
1877
2467
  constructor() {
1878
2468
  this.command = "tr";
1879
- this.description = "Translate or delete characters in a file";
2469
+ this.description = "Translate or delete characters in piped input";
1880
2470
  this.author = DefaultLibraryAuthor;
1881
2471
  this.version = LIBRARY_VERSION;
1882
2472
  this.acceptsRawInput = true;
@@ -1884,103 +2474,76 @@ var CliTrCommandProcessor = class {
1884
2474
  this.metadata = { icon: "\u{1F500}", module: "file management" };
1885
2475
  this.parameters = [
1886
2476
  {
1887
- name: "d",
2477
+ name: "delete",
2478
+ aliases: ["d"],
1888
2479
  description: "Delete characters in SET1",
1889
2480
  required: false,
1890
2481
  type: "boolean"
1891
2482
  },
1892
2483
  {
1893
- name: "s",
2484
+ name: "squeeze-repeats",
2485
+ aliases: ["s"],
1894
2486
  description: "Squeeze repeated characters in last operand set",
1895
2487
  required: false,
1896
2488
  type: "boolean"
1897
2489
  }
1898
2490
  ];
1899
2491
  }
1900
- async processCommand(command, context) {
1901
- const fs = context.services.get(
1902
- IFileSystemService_TOKEN
2492
+ writeDescription(context) {
2493
+ const t = context.translator;
2494
+ writeWrapped(
2495
+ context,
2496
+ t.t("cli.tr.help.translate_characters_set1_matching", "Translate characters from SET1 into the matching characters of SET2, delete them (-d) or squeeze repeats into one (-s). Sets can use ranges like a-z and classes like [:upper:], [:lower:], [:digit:], [:alpha:], [:alnum:] and [:space:]. Escapes \\n, \\t and \\\\ are understood. Like Unix tr, it reads only piped input and takes no file operands: to transform a file, pipe it in with cat.")
1903
2497
  );
2498
+ context.writer.writeln();
2499
+ writeUsage(context, [
2500
+ ["<command> | tr <set1> <set2>", t.t("cli.tr.help.replace_characters", "Replace characters")],
2501
+ ["<command> | tr -d <set1>", t.t("cli.tr.help.delete_characters", "Delete characters")],
2502
+ ["<command> | tr -s <set1> [set2]", t.t("cli.tr.help.squeeze_repeated_characters", "Squeeze repeated characters")]
2503
+ ]);
2504
+ context.writer.writeln();
2505
+ writeExamples(context, [
2506
+ ["cat notes.txt | tr a-z A-Z", t.t("cli.tr.help.convert_file_upper_case", "Convert a file to upper case")],
2507
+ ["echo hello | tr a-z A-Z", t.t("cli.tr.help.upper_case_piped_text", "Upper-case piped text")],
2508
+ ["cat notes.txt | tr -d 0-9", t.t("cli.tr.help.remove_all_digits", "Remove all digits")],
2509
+ ["cat notes.txt | tr -s ' '", t.t("cli.tr.help.collapse_runs_spaces", "Collapse runs of spaces")]
2510
+ ]);
2511
+ }
2512
+ async processCommand(command, context) {
1904
2513
  const raw = command.value || "";
1905
2514
  const positional = parseTokens(raw);
1906
- const deleteMode = !!command.args["d"];
1907
- const squeezeMode = !!command.args["s"];
2515
+ const deleteMode = !!command.args["d"] || !!command.args["delete"];
2516
+ const squeezeMode = !!command.args["s"] || !!command.args["squeeze-repeats"];
1908
2517
  if (positional.length === 0) {
1909
- context.writer.writeError("tr: missing operand");
2518
+ this.fail(context, "tr: missing operand");
1910
2519
  return;
1911
2520
  }
1912
- let set1Str;
1913
- let set2Str;
1914
- let filePath;
1915
- if (deleteMode && squeezeMode) {
1916
- if (positional.length < 2) {
1917
- context.writer.writeError("tr: missing operand");
1918
- return;
1919
- }
1920
- set1Str = positional[0];
1921
- set2Str = positional[1];
1922
- if (positional.length >= 3) {
1923
- filePath = positional[2];
1924
- }
1925
- } else if (deleteMode) {
1926
- if (positional.length < 1) {
1927
- context.writer.writeError("tr: missing operand");
1928
- return;
1929
- }
1930
- set1Str = positional[0];
1931
- if (positional.length >= 2) {
1932
- filePath = positional[1];
1933
- }
2521
+ let setCount;
2522
+ if (deleteMode) {
2523
+ setCount = squeezeMode ? 2 : 1;
1934
2524
  } else if (squeezeMode) {
1935
- if (positional.length < 1) {
1936
- context.writer.writeError("tr: missing operand");
1937
- return;
1938
- }
1939
- if (positional.length >= 3) {
1940
- set1Str = positional[0];
1941
- set2Str = positional[1];
1942
- filePath = positional[2];
1943
- } else if (positional.length === 2) {
1944
- set1Str = positional[0];
1945
- filePath = positional[1];
1946
- } else {
1947
- set1Str = positional[0];
1948
- }
2525
+ setCount = Math.min(positional.length, 2);
1949
2526
  } else {
1950
- if (positional.length < 2) {
1951
- context.writer.writeError("tr: missing operand");
1952
- return;
1953
- }
1954
- set1Str = positional[0];
1955
- set2Str = positional[1];
1956
- if (positional.length >= 3) {
1957
- filePath = positional[2];
1958
- }
2527
+ setCount = 2;
1959
2528
  }
1960
- let content;
1961
- if (filePath) {
1962
- try {
1963
- if (fs.isDirectory(filePath)) {
1964
- context.writer.writeError(`tr: ${filePath}: Is a directory`);
1965
- return;
1966
- }
1967
- content = fs.readFile(filePath);
1968
- if (content === null) {
1969
- context.writer.writeError(
1970
- `tr: ${filePath}: No such file or directory`
1971
- );
1972
- return;
1973
- }
1974
- } catch (e) {
1975
- context.writer.writeError(`tr: ${e.message}`);
1976
- return;
1977
- }
1978
- } else if (command.data != null) {
1979
- content = typeof command.data === "string" ? command.data : JSON.stringify(command.data);
1980
- } else {
1981
- context.writer.writeError("tr: missing file operand");
2529
+ if (positional.length < setCount) {
2530
+ this.fail(context, "tr: missing operand");
2531
+ return;
2532
+ }
2533
+ if (positional.length > setCount) {
2534
+ this.fail(context, `tr: extra operand '${positional[setCount]}'`);
2535
+ return;
2536
+ }
2537
+ if (command.data == null) {
2538
+ this.fail(
2539
+ context,
2540
+ "tr: no input: tr reads only piped input and takes no file operands; use 'cat file | tr a-z A-Z'"
2541
+ );
1982
2542
  return;
1983
2543
  }
2544
+ const set1Str = positional[0];
2545
+ const set2Str = setCount === 2 ? positional[1] : void 0;
2546
+ const content = typeof command.data === "string" ? command.data : JSON.stringify(command.data);
1984
2547
  try {
1985
2548
  const set1 = expandSet(set1Str);
1986
2549
  const set1Set = new Set(set1);
@@ -2003,9 +2566,13 @@ var CliTrCommandProcessor = class {
2003
2566
  }
2004
2567
  context.writer.writeln(result);
2005
2568
  } catch (e) {
2006
- context.writer.writeError(`tr: ${e.message}`);
2569
+ this.fail(context, `tr: ${e.message}`);
2007
2570
  }
2008
2571
  }
2572
+ fail(context, message) {
2573
+ context.writer.writeError(message);
2574
+ context.process.exit(1, { silent: true });
2575
+ }
2009
2576
  deleteChars(content, deleteSet) {
2010
2577
  let result = "";
2011
2578
  for (const ch of content) {
@@ -2052,8 +2619,24 @@ var CliStatCommandProcessor = class {
2052
2619
  this.metadata = { icon: "\u{1F4CA}", module: "file management" };
2053
2620
  this.parameters = [];
2054
2621
  }
2622
+ writeDescription(context) {
2623
+ const t = context.translator;
2624
+ writeWrapped(
2625
+ context,
2626
+ t.t("cli.stat.help.show_details_about_files", "Show details about files or directories: full path, size, type, permissions, owner, group, and when it was created and last modified.")
2627
+ );
2628
+ context.writer.writeln();
2629
+ writeUsage(context, [
2630
+ ["stat <path...>", t.t("cli.stat.help.show_details_each_path", "Show details for each path")]
2631
+ ]);
2632
+ context.writer.writeln();
2633
+ writeExamples(context, [
2634
+ ["stat notes.txt", t.t("cli.stat.help.details_one_file", "Details of one file")],
2635
+ ["stat ~/docs notes.txt", t.t("cli.stat.help.several_paths_once", "Several paths at once")]
2636
+ ]);
2637
+ }
2055
2638
  async processCommand(command, context) {
2056
- const fs = context.services.get(
2639
+ const fs = context.services.getRequired(
2057
2640
  IFileSystemService_TOKEN
2058
2641
  );
2059
2642
  const paths = this.parsePaths(command);
@@ -2063,6 +2646,10 @@ var CliStatCommandProcessor = class {
2063
2646
  }
2064
2647
  for (const path of paths) {
2065
2648
  const resolved = fs.resolvePath(path);
2649
+ if (fs.canTraverse && !fs.canTraverse(resolved)) {
2650
+ context.writer.writeError(`stat: cannot stat '${path}': Permission denied`);
2651
+ continue;
2652
+ }
2066
2653
  const node = fs.getNode(resolved);
2067
2654
  if (!node) {
2068
2655
  context.writer.writeError(
@@ -2084,7 +2671,7 @@ var CliStatCommandProcessor = class {
2084
2671
  }
2085
2672
  parsePaths(command) {
2086
2673
  const raw = command.value || "";
2087
- return raw.split(/\s+/).filter(Boolean);
2674
+ return splitArgs(raw).filter(Boolean);
2088
2675
  }
2089
2676
  };
2090
2677
  var CliChmodCommandProcessor = class {
@@ -2105,8 +2692,30 @@ var CliChmodCommandProcessor = class {
2105
2692
  }
2106
2693
  ];
2107
2694
  }
2695
+ writeDescription(context) {
2696
+ const t = context.translator;
2697
+ writeWrapped(
2698
+ context,
2699
+ t.t("cli.chmod.help.change_who_can_read", "Change who can read (r), write (w) and execute (x) a file. Use an octal mode such as 755 or a symbolic one: who (u user, g group, o others, a all), then + to add, - to remove or = to set, then the permissions.")
2700
+ );
2701
+ context.writer.writeln();
2702
+ writeUsage(context, [
2703
+ ["chmod <mode> <file...>", t.t("cli.chmod.help.set_permissions", "Set permissions")],
2704
+ [
2705
+ "chmod -R <mode> <dir>",
2706
+ t.t("cli.chmod.help.apply_directory_everything", "Apply to a directory and everything in it")
2707
+ ]
2708
+ ]);
2709
+ context.writer.writeln();
2710
+ writeExamples(context, [
2711
+ ["chmod 644 notes.txt", t.t("cli.chmod.help.rw_r_r_owner", "rw-r--r--: owner writes, others read")],
2712
+ ["chmod u+x script.sh", t.t("cli.chmod.help.let_owner_execute", "Let the owner execute it")],
2713
+ ["chmod go-w shared.txt", t.t("cli.chmod.help.take_away_write_group", "Take away write for group and others")],
2714
+ ["chmod -R 755 site", t.t("cli.chmod.help.set_whole_directory_tree", "Set a whole directory tree")]
2715
+ ]);
2716
+ }
2108
2717
  async processCommand(command, context) {
2109
- const fs = context.services.get(
2718
+ const fs = context.services.getRequired(
2110
2719
  IFileSystemService_TOKEN
2111
2720
  );
2112
2721
  const { recursive, mode, paths } = this.parseArgs(command);
@@ -2138,7 +2747,7 @@ var CliChmodCommandProcessor = class {
2138
2747
  }
2139
2748
  parseArgs(command) {
2140
2749
  const raw = command.value || "";
2141
- const tokens = raw.split(/\s+/).filter(Boolean);
2750
+ const tokens = splitArgs(raw).filter(Boolean);
2142
2751
  const recursive = command.args["R"] || false;
2143
2752
  const mode = tokens.length > 0 ? tokens[0] : null;
2144
2753
  const paths = tokens.slice(1);
@@ -2225,8 +2834,33 @@ var CliChownCommandProcessor = class {
2225
2834
  }
2226
2835
  ];
2227
2836
  }
2837
+ writeDescription(context) {
2838
+ const t = context.translator;
2839
+ writeWrapped(
2840
+ context,
2841
+ t.t("cli.chown.help.change_owner_group_files", "Change the owner and/or group of files. Give user, user:group, user: (owner only) or :group (group only).")
2842
+ );
2843
+ context.writer.writeln();
2844
+ writeUsage(context, [
2845
+ [
2846
+ "chown <user>[:<group>] <file...>",
2847
+ t.t("cli.chown.help.change_owner_group_if", "Change owner, and group if given")
2848
+ ],
2849
+ ["chown :<group> <file>", t.t("cli.chown.help.change_only_group", "Change only the group")],
2850
+ [
2851
+ "chown -R <user> <dir>",
2852
+ t.t("cli.chown.help.apply_directory_everything", "Apply to a directory and everything in it")
2853
+ ]
2854
+ ]);
2855
+ context.writer.writeln();
2856
+ writeExamples(context, [
2857
+ ["chown alice notes.txt", t.t("cli.chown.help.make_alice_owner", "Make alice the owner")],
2858
+ ["chown alice:staff report.txt", t.t("cli.chown.help.set_owner_group", "Set owner and group")],
2859
+ ["chown -R bob ~/shared", t.t("cli.chown.help.give_bob_whole_directory", "Give bob a whole directory")]
2860
+ ]);
2861
+ }
2228
2862
  async processCommand(command, context) {
2229
- const fs = context.services.get(
2863
+ const fs = context.services.getRequired(
2230
2864
  IFileSystemService_TOKEN
2231
2865
  );
2232
2866
  const { recursive, ownerSpec, paths } = this.parseArgs(command);
@@ -2258,7 +2892,7 @@ var CliChownCommandProcessor = class {
2258
2892
  }
2259
2893
  parseArgs(command) {
2260
2894
  const raw = command.value || "";
2261
- const tokens = raw.split(/\s+/).filter(Boolean);
2895
+ const tokens = splitArgs(raw).filter(Boolean);
2262
2896
  const recursive = command.args["R"] || false;
2263
2897
  const ownerSpec = tokens.length > 0 ? tokens[0] : null;
2264
2898
  const paths = tokens.slice(1);
@@ -2296,7 +2930,6 @@ var CliDuCommandProcessor = class {
2296
2930
  this.author = DefaultLibraryAuthor;
2297
2931
  this.version = LIBRARY_VERSION;
2298
2932
  this.acceptsRawInput = true;
2299
- this.valueRequired = true;
2300
2933
  this.metadata = { icon: "\u{1F4CF}", module: "file management" };
2301
2934
  this.parameters = [
2302
2935
  {
@@ -2320,17 +2953,39 @@ var CliDuCommandProcessor = class {
2320
2953
  }
2321
2954
  ];
2322
2955
  }
2956
+ writeDescription(context) {
2957
+ const t = context.translator;
2958
+ writeWrapped(
2959
+ context,
2960
+ t.t("cli.du.help.show_how_much_space", "Show how much space files and directories use, in bytes. For a directory, every subdirectory is listed with its total size; use -s for just the total or -d to limit how deep it goes.")
2961
+ );
2962
+ context.writer.writeln();
2963
+ writeUsage(context, [
2964
+ ["du [path...]", t.t("cli.du.help.sizes_path_default_current", "Sizes of a path (default: the current directory) and its subdirectories")],
2965
+ ["du -s <path>", t.t("cli.du.help.only_total", "Only the total")],
2966
+ ["du -d <n> <path>", t.t("cli.du.help.only_levels_deep", "Only <n> levels deep")]
2967
+ ]);
2968
+ context.writer.writeln();
2969
+ writeExamples(context, [
2970
+ ["du ~/docs", t.t("cli.du.help.size_each_directory_docs", "Size of each directory in ~/docs")],
2971
+ ["du -h -s ~", t.t("cli.du.help.total_home_k_m", "Total of your home in K/M/G")],
2972
+ ["du -h -d 1 .", t.t("cli.du.help.size_each_top_level", "Size of each top-level folder")]
2973
+ ]);
2974
+ }
2323
2975
  async processCommand(command, context) {
2324
- const fs = context.services.get(
2976
+ const fs = context.services.getRequired(
2325
2977
  IFileSystemService_TOKEN
2326
2978
  );
2327
2979
  const { humanReadable, summaryOnly, maxDepth, paths } = this.parseArgs(command);
2328
2980
  if (paths.length === 0) {
2329
- context.writer.writeError("du: missing operand");
2330
- return;
2981
+ paths.push(fs.getCurrentDirectory());
2331
2982
  }
2332
2983
  for (const path of paths) {
2333
2984
  const resolved = fs.resolvePath(path);
2985
+ if (fs.canTraverse && !fs.canTraverse(resolved)) {
2986
+ context.writer.writeError(`du: cannot access '${path}': Permission denied`);
2987
+ continue;
2988
+ }
2334
2989
  const node = fs.getNode(resolved);
2335
2990
  if (!node) {
2336
2991
  context.writer.writeError(
@@ -2344,6 +2999,7 @@ var CliDuCommandProcessor = class {
2344
2999
  );
2345
3000
  } else {
2346
3001
  const entries = [];
3002
+ this.fs = fs;
2347
3003
  this.collectSizes(node, resolved, 0, maxDepth, summaryOnly, entries);
2348
3004
  for (const entry of entries) {
2349
3005
  context.writer.writeln(
@@ -2355,10 +3011,12 @@ var CliDuCommandProcessor = class {
2355
3011
  }
2356
3012
  parseArgs(command) {
2357
3013
  const raw = command.value || "";
2358
- const paths = raw.split(/\s+/).filter(Boolean);
3014
+ const paths = splitArgs(raw).filter(Boolean);
2359
3015
  const humanReadable = !!command.args["h"];
2360
3016
  const summaryOnly = !!command.args["s"];
2361
- const maxDepth = command.args["d"] || command.args["max-depth"] ? parseInt(command.args["d"] || command.args["max-depth"] || "0", 10) : Infinity;
3017
+ const depthArg = command.args["max-depth"] ?? command.args["d"];
3018
+ const parsedDepth = parseInt(String(depthArg ?? ""), 10);
3019
+ const maxDepth = depthArg != null && depthArg !== true && !isNaN(parsedDepth) ? parsedDepth : Infinity;
2362
3020
  return { humanReadable, summaryOnly, maxDepth, paths };
2363
3021
  }
2364
3022
  collectSizes(node, path, depth, maxDepth, summaryOnly, entries) {
@@ -2366,8 +3024,9 @@ var CliDuCommandProcessor = class {
2366
3024
  return node.size;
2367
3025
  }
2368
3026
  let total = 0;
2369
- if (node.children) {
2370
- for (const child of node.children) {
3027
+ const children = this.fs ? readableChildren(this.fs, path) : node.children;
3028
+ if (children) {
3029
+ for (const child of children) {
2371
3030
  const childPath = path === "/" ? `/${child.name}` : `${path}/${child.name}`;
2372
3031
  total += this.collectSizes(
2373
3032
  child,
@@ -2414,11 +3073,34 @@ var CliLnCommandProcessor = class {
2414
3073
  description: "Create a symbolic link",
2415
3074
  required: false,
2416
3075
  type: "boolean"
3076
+ },
3077
+ {
3078
+ name: "force",
3079
+ aliases: ["f"],
3080
+ description: "Replace an existing file at the link path",
3081
+ required: false,
3082
+ type: "boolean"
2417
3083
  }
2418
3084
  ];
2419
3085
  }
3086
+ writeDescription(context) {
3087
+ const t = context.translator;
3088
+ writeWrapped(
3089
+ context,
3090
+ t.t("cli.ln.help.create_symbolic_link_new", "Create a symbolic link: a new path that points to another file or directory. Only symbolic links (-s) are supported, and the target does not need to exist yet.")
3091
+ );
3092
+ context.writer.writeln();
3093
+ writeUsage(context, [
3094
+ ["ln -s <target> <link>", t.t("cli.ln.help.create_pointing", "Create <link> pointing at <target>")]
3095
+ ]);
3096
+ context.writer.writeln();
3097
+ writeExamples(context, [
3098
+ ["ln -s ~/docs/notes.txt notes", t.t("cli.ln.help.shortcut_file", "Shortcut to a file")],
3099
+ ["ln -s /var/log logs", t.t("cli.ln.help.shortcut_directory", "Shortcut to a directory")]
3100
+ ]);
3101
+ }
2420
3102
  async processCommand(command, context) {
2421
- const fs = context.services.get(
3103
+ const fs = context.services.getRequired(
2422
3104
  IFileSystemService_TOKEN
2423
3105
  );
2424
3106
  const { symbolic, target, linkName } = this.parseArgs(command);
@@ -2434,6 +3116,10 @@ var CliLnCommandProcessor = class {
2434
3116
  }
2435
3117
  const resolvedLink = fs.resolvePath(linkName);
2436
3118
  const resolvedTarget = fs.resolvePath(target);
3119
+ const force = !!(command.args["force"] || command.args["f"]);
3120
+ if (force && fs.exists(resolvedLink) && !fs.isDirectory(resolvedLink)) {
3121
+ fs.remove(resolvedLink);
3122
+ }
2437
3123
  if (fs.exists(resolvedLink)) {
2438
3124
  context.writer.writeError(
2439
3125
  `ln: failed to create symbolic link '${linkName}': File exists`
@@ -2450,7 +3136,7 @@ var CliLnCommandProcessor = class {
2450
3136
  }
2451
3137
  parseArgs(command) {
2452
3138
  const raw = command.value || "";
2453
- const tokens = raw.split(/\s+/).filter(Boolean);
3139
+ const tokens = splitArgs(raw).filter(Boolean);
2454
3140
  const symbolic = command.args["s"] || false;
2455
3141
  return {
2456
3142
  symbolic,
@@ -2485,25 +3171,75 @@ var CliSedCommandProcessor = class {
2485
3171
  {
2486
3172
  name: "expression",
2487
3173
  aliases: ["e"],
2488
- description: "Add the expression to the commands to be executed",
3174
+ description: "Add the expression to the commands to be executed (repeatable)",
2489
3175
  required: false,
2490
- type: "string"
3176
+ type: "array"
3177
+ },
3178
+ {
3179
+ name: "regexp-extended",
3180
+ aliases: ["E", "r"],
3181
+ description: "Use extended regular expressions: (, ), +, ?, | and { } without backslashes",
3182
+ required: false,
3183
+ type: "boolean"
2491
3184
  }
2492
3185
  ];
2493
3186
  }
3187
+ writeDescription(context) {
3188
+ const t = context.translator;
3189
+ writeWrapped(
3190
+ context,
3191
+ t.t("cli.sed.help.edit_text_line_line", "Edit text line by line with a script: substitute (s/old/new/), delete (d), print (p) or quit (q), optionally limited to a line number, a range (1,5, 3,$ or /start/,/end/) or lines matching /regex/; ! after an address inverts it and ; separates commands. The replacement can use & (the match) and \\1 to \\9 (groups, written \\(...\\) or, with -E, (...)). Reads files or piped input and prints the result, or rewrites the files with -i.")
3192
+ );
3193
+ context.writer.writeln();
3194
+ writeUsage(context, [
3195
+ ["sed '<script>' <file>", t.t("cli.sed.help.print_edited_file", "Print the edited file")],
3196
+ ["sed -i '<script>' <file>", t.t("cli.sed.help.save_edits_file", "Save the edits into the file")],
3197
+ ["sed -n '<script>p' <file>", t.t("cli.sed.help.print_only_selected_lines", "Print only selected lines")],
3198
+ ["<command> | sed '<script>'", t.t("cli.sed.help.edit_piped_input", "Edit piped input")]
3199
+ ]);
3200
+ context.writer.writeln();
3201
+ writeExamples(context, [
3202
+ ["sed 's/cat/dog/g' pets.txt", t.t("cli.sed.help.replace_every_cat_dog", "Replace every cat with dog")],
3203
+ ["sed -i 's/v1/v2/' config.txt", t.t("cli.sed.help.edit_file_place", "Edit the file in place")],
3204
+ ["sed '/^#/d' config.txt", t.t("cli.sed.help.drop_comment_lines", "Drop comment lines")],
3205
+ ["sed -n '1,5p' notes.txt", t.t("cli.sed.help.print_lines_1_5", "Print lines 1 to 5")],
3206
+ ["sed -E 's/(\\w+) (\\w+)/\\2 \\1/' names.txt", t.t("cli.sed.help.swap_two_words", "Swap two words")]
3207
+ ]);
3208
+ }
2494
3209
  async processCommand(command, context) {
2495
- const fs = context.services.get(
3210
+ const fs = context.services.getRequired(
2496
3211
  IFileSystemService_TOKEN
2497
3212
  );
2498
- const { inPlace, suppress, expressions, filePath } = this.parseRawCommand(command);
3213
+ const { inPlace, suppress, extended, expressions, filePaths } = this.parseRawCommand(command);
2499
3214
  if (expressions.length === 0) {
2500
3215
  context.writer.writeError(
2501
3216
  "sed: missing expression. Usage: sed [options] 'expression' <file>"
2502
3217
  );
2503
3218
  return;
2504
3219
  }
2505
- let content;
2506
- if (filePath) {
3220
+ const parse = () => {
3221
+ try {
3222
+ return expressions.flatMap((e) => this.parseScript(e, extended));
3223
+ } catch (e) {
3224
+ context.writer.writeError(`sed: ${e.message}`);
3225
+ return null;
3226
+ }
3227
+ };
3228
+ if (filePaths.length === 0) {
3229
+ if (command.data == null) {
3230
+ context.writer.writeError(
3231
+ "sed: missing file operand. Usage: sed [options] 'expression' <file>"
3232
+ );
3233
+ return;
3234
+ }
3235
+ const script2 = parse();
3236
+ if (!script2) return;
3237
+ const content = typeof command.data === "string" ? command.data : JSON.stringify(command.data);
3238
+ this.writeLines(context, this.run(script2, splitLines(content), suppress));
3239
+ return;
3240
+ }
3241
+ const contents = [];
3242
+ for (const filePath of filePaths) {
2507
3243
  const resolved = fs.resolvePath(filePath);
2508
3244
  if (!fs.exists(resolved)) {
2509
3245
  context.writer.writeError(
@@ -2515,116 +3251,125 @@ var CliSedCommandProcessor = class {
2515
3251
  context.writer.writeError(`sed: ${filePath}: Is a directory`);
2516
3252
  return;
2517
3253
  }
2518
- content = fs.readFile(resolved) ?? "";
2519
- } else if (command.data != null) {
2520
- content = typeof command.data === "string" ? command.data : JSON.stringify(command.data);
2521
- } else {
2522
- context.writer.writeError(
2523
- "sed: missing file operand. Usage: sed [options] 'expression' <file>"
2524
- );
2525
- return;
2526
- }
2527
- const parsedExpressions = [];
2528
- for (const expr of expressions) {
2529
3254
  try {
2530
- const parsed = this.parseExpression(expr);
2531
- parsedExpressions.push(parsed);
3255
+ contents.push(fs.readFile(resolved) ?? "");
2532
3256
  } catch (e) {
2533
3257
  context.writer.writeError(`sed: ${e.message}`);
2534
3258
  return;
2535
3259
  }
2536
3260
  }
2537
- const lines = content.split("\n");
2538
- const outputLines = [];
3261
+ if (inPlace) {
3262
+ for (let i = 0; i < filePaths.length; i++) {
3263
+ const script2 = parse();
3264
+ if (!script2) return;
3265
+ const output = this.run(script2, splitLines(contents[i]), suppress);
3266
+ const keepNewline = contents[i].endsWith("\n") && output.length > 0;
3267
+ try {
3268
+ fs.writeFile(
3269
+ fs.resolvePath(filePaths[i]),
3270
+ output.join("\n") + (keepNewline ? "\n" : "")
3271
+ );
3272
+ } catch (e) {
3273
+ context.writer.writeError(`sed: ${e.message}`);
3274
+ return;
3275
+ }
3276
+ }
3277
+ await fs.persist();
3278
+ return;
3279
+ }
3280
+ const script = parse();
3281
+ if (!script) return;
3282
+ const lines = contents.flatMap((c) => splitLines(c));
3283
+ this.writeLines(context, this.run(script, lines, suppress));
3284
+ }
3285
+ writeLines(context, lines) {
3286
+ for (const line of lines) {
3287
+ context.writer.writeln(line);
3288
+ }
3289
+ }
3290
+ /** Runs the script over the lines and returns the output lines. */
3291
+ run(script, lines, suppress) {
3292
+ const output = [];
2539
3293
  for (let lineIdx = 0; lineIdx < lines.length; lineIdx++) {
2540
3294
  let line = lines[lineIdx];
2541
3295
  let deleted = false;
2542
- let printed = false;
2543
- for (const expr of parsedExpressions) {
2544
- if (deleted) break;
2545
- if (!this.addressMatches(
2546
- expr.address,
2547
- lineIdx + 1,
2548
- lines.length,
2549
- line
2550
- )) {
3296
+ let quit = false;
3297
+ for (const expr of script) {
3298
+ if (!this.addressMatches(expr, lineIdx + 1, lines.length, line)) {
2551
3299
  continue;
2552
3300
  }
2553
- switch (expr.command) {
2554
- case "s": {
2555
- const pattern = expr.pattern;
2556
- pattern.lastIndex = 0;
2557
- const newLine = line.replace(pattern, (...args) => {
2558
- const match = args[0];
2559
- return expr.replacement.replace(/&/g, match);
2560
- });
2561
- const matched = newLine !== line;
2562
- line = newLine;
2563
- if (matched && expr.printFlag) {
2564
- printed = true;
3301
+ if (expr.command === "s") {
3302
+ let replaced = false;
3303
+ let seen = 0;
3304
+ const pattern = expr.pattern;
3305
+ pattern.lastIndex = 0;
3306
+ line = line.replace(pattern, (...args) => {
3307
+ seen++;
3308
+ const nth = expr.occurrence;
3309
+ if (nth && (expr.globalFlag ? seen < nth : seen !== nth)) {
3310
+ return args[0];
2565
3311
  }
2566
- break;
2567
- }
2568
- case "d":
2569
- deleted = true;
2570
- break;
2571
- case "p":
2572
- printed = true;
2573
- break;
3312
+ replaced = true;
3313
+ const groups = args.slice(0, expr.groupCount + 1);
3314
+ return this.expandReplacement(expr.replacement, groups);
3315
+ });
3316
+ if (replaced && expr.printFlag) output.push(line);
3317
+ } else if (expr.command === "p") {
3318
+ output.push(line);
3319
+ } else if (expr.command === "d") {
3320
+ deleted = true;
3321
+ break;
3322
+ } else if (expr.command === "q") {
3323
+ quit = true;
3324
+ break;
2574
3325
  }
2575
3326
  }
2576
- if (!deleted) {
2577
- if (suppress) {
2578
- if (printed) {
2579
- outputLines.push(line);
2580
- }
3327
+ if (!deleted && !suppress) output.push(line);
3328
+ if (quit) break;
3329
+ }
3330
+ return output;
3331
+ }
3332
+ /** `&` is the match, `\1`..`\9` the groups, `\n` a newline, `\x` a literal x. */
3333
+ expandReplacement(replacement, groups) {
3334
+ let out = "";
3335
+ for (let i = 0; i < replacement.length; i++) {
3336
+ const ch = replacement[i];
3337
+ if (ch === "\\" && i + 1 < replacement.length) {
3338
+ const next = replacement[++i];
3339
+ if (next >= "0" && next <= "9") {
3340
+ out += groups[Number(next)] ?? "";
3341
+ } else if (next === "n") {
3342
+ out += "\n";
3343
+ } else if (next === "t") {
3344
+ out += " ";
2581
3345
  } else {
2582
- outputLines.push(line);
3346
+ out += next;
2583
3347
  }
3348
+ } else if (ch === "&") {
3349
+ out += groups[0];
3350
+ } else {
3351
+ out += ch;
2584
3352
  }
2585
3353
  }
2586
- const result = outputLines.join("\n");
2587
- if (inPlace && filePath) {
2588
- const resolved = fs.resolvePath(filePath);
2589
- fs.writeFile(resolved, result);
2590
- await fs.persist();
2591
- } else {
2592
- const resultLines = result.split("\n");
2593
- for (const line of resultLines) {
2594
- context.writer.writeln(line);
2595
- }
2596
- }
3354
+ return out;
2597
3355
  }
2598
3356
  parseRawCommand(command) {
2599
3357
  const raw = command.value || "";
2600
3358
  const tokens = this.tokenize(raw);
2601
3359
  const inPlace = !!command.args["in-place"] || !!command.args["i"];
2602
3360
  const suppress = !!command.args["quiet"] || !!command.args["n"];
3361
+ const extended = !!command.args["regexp-extended"] || !!command.args["E"] || !!command.args["r"];
2603
3362
  const expressions = [];
2604
- let filePath = null;
2605
- const nonFlagTokens = [];
2606
- if (command.args["expression"] || command.args["e"]) {
2607
- const eValue = command.args["expression"] || command.args["e"];
2608
- if (Array.isArray(eValue)) {
2609
- for (const v of eValue) {
2610
- if (v && typeof v === "string") {
2611
- expressions.push(v);
2612
- }
2613
- }
2614
- } else if (eValue && typeof eValue === "string") {
2615
- expressions.push(eValue);
3363
+ const eValue = command.args["expression"] ?? command.args["e"];
3364
+ for (const v of Array.isArray(eValue) ? eValue : [eValue]) {
3365
+ if (v != null && v !== true && v !== "") {
3366
+ expressions.push(String(v));
2616
3367
  }
2617
3368
  }
2618
- for (const t of tokens) {
2619
- nonFlagTokens.push(t);
2620
- }
2621
- if (expressions.length === 0 && nonFlagTokens.length > 0) {
2622
- expressions.push(nonFlagTokens.shift());
2623
- }
2624
- if (nonFlagTokens.length > 0) {
2625
- filePath = nonFlagTokens[nonFlagTokens.length - 1];
3369
+ if (expressions.length === 0 && tokens.length > 0) {
3370
+ expressions.push(tokens.shift());
2626
3371
  }
2627
- return { inPlace, suppress, expressions, filePath };
3372
+ return { inPlace, suppress, extended, expressions, filePaths: tokens };
2628
3373
  }
2629
3374
  tokenize(raw) {
2630
3375
  const tokens = [];
@@ -2634,73 +3379,85 @@ var CliSedCommandProcessor = class {
2634
3379
  i++;
2635
3380
  continue;
2636
3381
  }
2637
- if (raw[i] === "'" || raw[i] === '"') {
2638
- const quote = raw[i];
2639
- i++;
2640
- let token2 = "";
2641
- while (i < raw.length && raw[i] !== quote) {
2642
- token2 += raw[i];
2643
- i++;
2644
- }
2645
- if (i < raw.length) i++;
2646
- tokens.push(token2);
2647
- continue;
2648
- }
2649
3382
  let token = "";
2650
3383
  while (i < raw.length && raw[i] !== " " && raw[i] !== " ") {
2651
- token += raw[i];
2652
- i++;
3384
+ if (raw[i] === "'" || raw[i] === '"') {
3385
+ const quote = raw[i];
3386
+ i++;
3387
+ while (i < raw.length && raw[i] !== quote) {
3388
+ token += raw[i];
3389
+ i++;
3390
+ }
3391
+ if (i < raw.length) i++;
3392
+ } else {
3393
+ token += raw[i];
3394
+ i++;
3395
+ }
2653
3396
  }
2654
3397
  tokens.push(token);
2655
3398
  }
2656
3399
  return tokens;
2657
3400
  }
2658
- parseExpression(expr) {
3401
+ /** Commands of a script, separated by `;` or newlines. */
3402
+ parseScript(script, extended) {
3403
+ const result = [];
2659
3404
  let pos = 0;
2660
- let address = null;
2661
- if (pos < expr.length) {
2662
- const addrResult = this.parseAddress(expr, pos);
2663
- if (addrResult) {
2664
- address = addrResult.address;
2665
- pos = addrResult.pos;
2666
- if (pos < expr.length && expr[pos] === ",") {
2667
- pos++;
2668
- const endResult = this.parseAddress(expr, pos);
2669
- if (endResult) {
2670
- if (endResult.address.type === "last") {
2671
- address = {
2672
- type: "range",
2673
- startLine: address.line,
2674
- endLine: 0,
2675
- endIsLast: true
2676
- };
2677
- } else {
2678
- address = {
2679
- type: "range",
2680
- startLine: address.line,
2681
- endLine: endResult.address.line
2682
- };
2683
- }
2684
- pos = endResult.pos;
2685
- }
3405
+ const skipSeparators = () => {
3406
+ while (pos < script.length && /[\s;]/.test(script[pos])) pos++;
3407
+ };
3408
+ skipSeparators();
3409
+ while (pos < script.length) {
3410
+ const parsed = this.parseCommand(script, pos, extended);
3411
+ result.push(parsed.expression);
3412
+ pos = parsed.pos;
3413
+ while (pos < script.length && /[ \t]/.test(script[pos])) pos++;
3414
+ if (pos < script.length && !/[;\n]/.test(script[pos])) {
3415
+ throw new Error(`extra characters after command: '${script.slice(pos)}'`);
3416
+ }
3417
+ skipSeparators();
3418
+ }
3419
+ if (result.length === 0) {
3420
+ throw new Error(`invalid expression: '${script}'`);
3421
+ }
3422
+ return result;
3423
+ }
3424
+ parseCommand(expr, pos, extended) {
3425
+ let start = null;
3426
+ let end = null;
3427
+ const first = this.parseAddress(expr, pos, extended);
3428
+ if (first) {
3429
+ start = first.address;
3430
+ pos = first.pos;
3431
+ if (expr[pos] === ",") {
3432
+ const second = this.parseAddress(expr, pos + 1, extended);
3433
+ if (!second) {
3434
+ throw new Error(`unexpected ',' in '${expr}'`);
2686
3435
  }
3436
+ end = second.address;
3437
+ pos = second.pos;
2687
3438
  }
2688
3439
  }
3440
+ while (pos < expr.length && /[ \t]/.test(expr[pos])) pos++;
3441
+ let negate = false;
3442
+ if (expr[pos] === "!") {
3443
+ negate = true;
3444
+ pos++;
3445
+ while (pos < expr.length && /[ \t]/.test(expr[pos])) pos++;
3446
+ }
2689
3447
  if (pos >= expr.length) {
2690
3448
  throw new Error(`invalid expression: '${expr}'`);
2691
3449
  }
3450
+ const base = { start, end, negate, active: false };
2692
3451
  const cmd = expr[pos];
2693
3452
  if (cmd === "s") {
2694
- return this.parseSubstitution(expr, pos, address);
2695
- } else if (cmd === "d") {
2696
- return { address, command: "d" };
2697
- } else if (cmd === "p") {
2698
- return { address, command: "p" };
2699
- } else {
2700
- throw new Error(`unknown command: '${cmd}'`);
3453
+ return this.parseSubstitution(expr, pos, base, extended);
2701
3454
  }
3455
+ if (cmd === "d" || cmd === "p" || cmd === "q") {
3456
+ return { expression: { ...base, command: cmd }, pos: pos + 1 };
3457
+ }
3458
+ throw new Error(`unknown command: '${cmd}'`);
2702
3459
  }
2703
- parseAddress(expr, pos) {
3460
+ parseAddress(expr, pos, extended) {
2704
3461
  if (pos >= expr.length) return null;
2705
3462
  if (expr[pos] >= "0" && expr[pos] <= "9") {
2706
3463
  let numStr = "";
@@ -2717,51 +3474,73 @@ var CliSedCommandProcessor = class {
2717
3474
  return { address: { type: "last" }, pos: pos + 1 };
2718
3475
  }
2719
3476
  if (expr[pos] === "/") {
2720
- pos++;
2721
- let pattern = "";
2722
- while (pos < expr.length && expr[pos] !== "/") {
2723
- if (expr[pos] === "\\" && pos + 1 < expr.length) {
2724
- pattern += expr[pos + 1];
2725
- pos += 2;
2726
- } else {
2727
- pattern += expr[pos];
2728
- pos++;
2729
- }
2730
- }
2731
- if (pos < expr.length) pos++;
3477
+ const parsed = this.readDelimited(expr, pos + 1, "/");
2732
3478
  try {
2733
3479
  return {
2734
- address: { type: "regex", regex: new RegExp(pattern) },
2735
- pos
3480
+ address: {
3481
+ type: "regex",
3482
+ regex: new RegExp(this.toJsRegex(parsed.text, extended))
3483
+ },
3484
+ pos: parsed.pos
2736
3485
  };
2737
3486
  } catch {
2738
- throw new Error(`invalid regex in address: '${pattern}'`);
3487
+ throw new Error(`invalid regex in address: '${parsed.text}'`);
2739
3488
  }
2740
3489
  }
2741
3490
  return null;
2742
3491
  }
2743
- parseSubstitution(expr, pos, address) {
2744
- pos++;
2745
- if (pos >= expr.length) {
2746
- throw new Error(`invalid substitution expression: '${expr}'`);
2747
- }
2748
- const delimiter = expr[pos];
2749
- pos++;
2750
- let pattern = "";
3492
+ /**
3493
+ * Text up to the next unescaped delimiter, escapes kept (an escaped
3494
+ * delimiter becomes the plain character when it has no regex meaning).
3495
+ */
3496
+ readDelimited(expr, pos, delimiter) {
3497
+ let text = "";
2751
3498
  while (pos < expr.length && expr[pos] !== delimiter) {
2752
3499
  if (expr[pos] === "\\" && pos + 1 < expr.length) {
2753
- pattern += "\\" + expr[pos + 1];
3500
+ const next = expr[pos + 1];
3501
+ text += next === delimiter && /\w|\//.test(next) ? next : "\\" + next;
2754
3502
  pos += 2;
2755
3503
  } else {
2756
- pattern += expr[pos];
3504
+ text += expr[pos];
2757
3505
  pos++;
2758
3506
  }
2759
3507
  }
2760
- if (pos < expr.length) pos++;
3508
+ if (pos < expr.length) pos++;
3509
+ return { text, pos };
3510
+ }
3511
+ /**
3512
+ * Basic regular expressions use \( \) \{ \} \+ \? \| for groups and
3513
+ * operators. When a pattern uses them (or never in -E mode), they are
3514
+ * turned into the JavaScript form and the bare characters made literal.
3515
+ */
3516
+ toJsRegex(pattern, extended) {
3517
+ if (extended || !/\\[(){}+?|]/.test(pattern)) return pattern;
3518
+ let out = "";
3519
+ for (let i = 0; i < pattern.length; i++) {
3520
+ const ch = pattern[i];
3521
+ if (ch === "\\" && i + 1 < pattern.length) {
3522
+ const next = pattern[++i];
3523
+ out += "(){}+?|".includes(next) ? next : "\\" + next;
3524
+ } else if ("(){}+?|".includes(ch)) {
3525
+ out += "\\" + ch;
3526
+ } else {
3527
+ out += ch;
3528
+ }
3529
+ }
3530
+ return out;
3531
+ }
3532
+ parseSubstitution(expr, pos, base, extended) {
3533
+ pos++;
3534
+ if (pos >= expr.length) {
3535
+ throw new Error(`invalid substitution expression: '${expr}'`);
3536
+ }
3537
+ const delimiter = expr[pos];
3538
+ const pattern = this.readDelimited(expr, pos + 1, delimiter);
3539
+ pos = pattern.pos;
2761
3540
  let replacement = "";
2762
3541
  while (pos < expr.length && expr[pos] !== delimiter) {
2763
3542
  if (expr[pos] === "\\" && pos + 1 < expr.length) {
2764
- replacement += expr[pos + 1];
3543
+ replacement += expr[pos + 1] === delimiter ? delimiter : "\\" + expr[pos + 1];
2765
3544
  pos += 2;
2766
3545
  } else {
2767
3546
  replacement += expr[pos];
@@ -2772,47 +3551,75 @@ var CliSedCommandProcessor = class {
2772
3551
  let globalFlag = false;
2773
3552
  let caseInsensitive = false;
2774
3553
  let printFlag = false;
2775
- while (pos < expr.length) {
3554
+ let occurrence = "";
3555
+ while (pos < expr.length && !/[;\n\s}]/.test(expr[pos])) {
2776
3556
  const f = expr[pos];
2777
3557
  if (f === "g") globalFlag = true;
2778
- else if (f === "i") caseInsensitive = true;
3558
+ else if (f === "i" || f === "I") caseInsensitive = true;
2779
3559
  else if (f === "p") printFlag = true;
3560
+ else if (f >= "0" && f <= "9") occurrence += f;
3561
+ else throw new Error(`unknown option to 's': '${f}'`);
2780
3562
  pos++;
2781
3563
  }
3564
+ const nth = occurrence ? parseInt(occurrence, 10) : void 0;
3565
+ if (nth === 0) {
3566
+ throw new Error("number option to 's' command may not be zero");
3567
+ }
2782
3568
  let regexFlags = "";
2783
3569
  if (caseInsensitive) regexFlags += "i";
2784
- if (globalFlag) regexFlags += "g";
3570
+ if (globalFlag || nth) regexFlags += "g";
2785
3571
  let regex;
3572
+ let groupCount;
2786
3573
  try {
2787
- regex = new RegExp(pattern, regexFlags);
3574
+ const source = this.toJsRegex(pattern.text, extended);
3575
+ regex = new RegExp(source, regexFlags);
3576
+ groupCount = (new RegExp(`${source}|`).exec("")?.length ?? 1) - 1;
2788
3577
  } catch {
2789
- throw new Error(`invalid regex: '${pattern}'`);
3578
+ throw new Error(`invalid regex: '${pattern.text}'`);
2790
3579
  }
2791
3580
  return {
2792
- address,
2793
- command: "s",
2794
- pattern: regex,
2795
- replacement,
2796
- globalFlag,
2797
- printFlag
3581
+ expression: {
3582
+ ...base,
3583
+ command: "s",
3584
+ pattern: regex,
3585
+ replacement,
3586
+ groupCount,
3587
+ printFlag,
3588
+ occurrence: nth,
3589
+ globalFlag
3590
+ },
3591
+ pos
2798
3592
  };
2799
3593
  }
2800
- addressMatches(address, lineNum, totalLines, lineText) {
2801
- if (!address) return true;
3594
+ addressMatches(expr, lineNum, totalLines, lineText) {
3595
+ let matches;
3596
+ if (!expr.start) {
3597
+ matches = true;
3598
+ } else if (!expr.end) {
3599
+ matches = this.singleMatches(expr.start, lineNum, totalLines, lineText);
3600
+ } else if (!expr.active) {
3601
+ matches = this.singleMatches(expr.start, lineNum, totalLines, lineText);
3602
+ if (matches) {
3603
+ const end = expr.end;
3604
+ const endsHere = end.type === "line" && end.line <= lineNum || end.type === "last" && lineNum === totalLines;
3605
+ expr.active = !endsHere;
3606
+ }
3607
+ } else {
3608
+ matches = true;
3609
+ const end = expr.end;
3610
+ const endsHere = end.type === "line" ? lineNum >= end.line : this.singleMatches(end, lineNum, totalLines, lineText);
3611
+ if (endsHere) expr.active = false;
3612
+ }
3613
+ return expr.negate ? !matches : matches;
3614
+ }
3615
+ singleMatches(address, lineNum, totalLines, lineText) {
2802
3616
  switch (address.type) {
2803
3617
  case "line":
2804
3618
  return lineNum === address.line;
2805
3619
  case "last":
2806
3620
  return lineNum === totalLines;
2807
- case "range": {
2808
- const start = address.startLine;
2809
- const end = address.endIsLast ? totalLines : address.endLine;
2810
- return lineNum >= start && lineNum <= end;
2811
- }
2812
3621
  case "regex":
2813
3622
  return address.regex.test(lineText);
2814
- default:
2815
- return true;
2816
3623
  }
2817
3624
  }
2818
3625
  };
@@ -2834,11 +3641,34 @@ var CliAwkCommandProcessor = class {
2834
3641
  }
2835
3642
  ];
2836
3643
  }
3644
+ writeDescription(context) {
3645
+ const t = context.translator;
3646
+ writeWrapped(
3647
+ context,
3648
+ t.t("cli.awk.help.process_text_records_fields", "Process text as records and fields: each line is split into fields $1, $2, ... ($0 is the whole line), and each rule runs its { action } on matching lines. Supports BEGIN and END blocks, /regex/ and field comparison patterns, print, variables, NR and NF. Quote the program; a simple one also works unquoted (awk {print $1} file).")
3649
+ );
3650
+ context.writer.writeln();
3651
+ writeUsage(context, [
3652
+ ["awk '<program>' <file>", t.t("cli.awk.help.run_program_over_file", "Run a program over a file")],
3653
+ [
3654
+ "awk -F <sep> '<program>' <file>",
3655
+ t.t("cli.awk.help.split_fields_instead_spaces", "Split fields on <sep> instead of spaces")
3656
+ ],
3657
+ ["<command> | awk '<program>'", t.t("cli.awk.help.run_program_over_piped", "Run a program over piped input")]
3658
+ ]);
3659
+ context.writer.writeln();
3660
+ writeExamples(context, [
3661
+ ["awk '{print $1}' data.txt", t.t("cli.awk.help.print_first_column", "Print the first column")],
3662
+ ["awk -F , '{print $2}' data.csv", t.t("cli.awk.help.second_column_csv", "Second column of a CSV")],
3663
+ ["awk '$3 > 100 {print $1}' sales.txt", t.t("cli.awk.help.filter_rows_field", "Filter rows by a field")],
3664
+ ["awk '{s += $2} END {print s}' data.txt", t.t("cli.awk.help.sum_second_column", "Sum the second column")]
3665
+ ]);
3666
+ }
2837
3667
  async processCommand(command, context) {
2838
- const fs = context.services.get(
3668
+ const fs = context.services.getRequired(
2839
3669
  IFileSystemService_TOKEN
2840
3670
  );
2841
- const { program, filePath, fieldSep } = this.parseArgs(command);
3671
+ const { program, filePaths, fieldSep } = this.parseArgs(command, fs);
2842
3672
  if (!program) {
2843
3673
  context.writer.writeError(
2844
3674
  "awk: missing program. Usage: awk [-F sep] 'program' file"
@@ -2846,18 +3676,21 @@ var CliAwkCommandProcessor = class {
2846
3676
  return;
2847
3677
  }
2848
3678
  let content = "";
2849
- if (filePath) {
2850
- try {
2851
- if (!fs.exists(filePath)) {
2852
- context.writer.writeError(
2853
- `awk: ${filePath}: No such file or directory`
2854
- );
3679
+ if (filePaths.length > 0) {
3680
+ for (const filePath of filePaths) {
3681
+ try {
3682
+ if (!fs.exists(filePath)) {
3683
+ context.writer.writeError(
3684
+ `awk: ${filePath}: No such file or directory`
3685
+ );
3686
+ return;
3687
+ }
3688
+ const text = fs.readFile(filePath) ?? "";
3689
+ content += text === "" || text.endsWith("\n") ? text : text + "\n";
3690
+ } catch (e) {
3691
+ context.writer.writeError(`awk: ${e.message}`);
2855
3692
  return;
2856
3693
  }
2857
- content = fs.readFile(filePath) ?? "";
2858
- } catch (e) {
2859
- context.writer.writeError(`awk: ${e.message}`);
2860
- return;
2861
3694
  }
2862
3695
  } else if (command.data != null) {
2863
3696
  content = typeof command.data === "string" ? command.data : JSON.stringify(command.data);
@@ -2919,24 +3752,58 @@ var CliAwkCommandProcessor = class {
2919
3752
  context.writer.writeln(line);
2920
3753
  }
2921
3754
  }
2922
- parseArgs(command) {
2923
- const raw = command.value || "";
2924
- const fieldSep = command.args["F"] || null;
2925
- let rest = raw;
2926
- let program = null;
2927
- const sqMatch = rest.match(/^'((?:[^'\\]|\\.)*)'\s*/);
2928
- if (sqMatch) {
2929
- program = sqMatch[1];
2930
- rest = rest.slice(sqMatch[0].length);
2931
- } else {
2932
- const dqMatch = rest.match(/^"((?:[^"\\]|\\.)*)"\s*/);
2933
- if (dqMatch) {
2934
- program = dqMatch[1];
2935
- rest = rest.slice(dqMatch[0].length);
3755
+ /**
3756
+ * The program is the first operand, usually quoted. Unquoted, it may span
3757
+ * several words (`awk {print $1} file`): it is then everything before the
3758
+ * trailing operands that are existing files, or the whole value with
3759
+ * piped input.
3760
+ */
3761
+ parseArgs(command, fs) {
3762
+ const raw = (command.value || "").trim();
3763
+ const fieldArg = command.args["F"];
3764
+ const fieldSep = fieldArg != null && fieldArg !== true ? String(fieldArg) : null;
3765
+ const quoted = raw.match(/^'((?:[^'\\]|\\.)*)'(?:\s+|$)/) ?? raw.match(/^"((?:[^"\\]|\\.)*)"(?:\s+|$)/);
3766
+ if (quoted) {
3767
+ return {
3768
+ program: quoted[1],
3769
+ filePaths: splitArgs(raw.slice(quoted[0].length)),
3770
+ fieldSep
3771
+ };
3772
+ }
3773
+ const words = raw.split(/\s+/).filter(Boolean);
3774
+ if (words.length === 0) {
3775
+ return { program: null, filePaths: [], fieldSep };
3776
+ }
3777
+ const isFile = (word) => {
3778
+ try {
3779
+ const path = splitArgs(word).join(" ");
3780
+ return fs.exists(path) && !fs.isDirectory(path);
3781
+ } catch {
3782
+ return false;
3783
+ }
3784
+ };
3785
+ let split = words.length;
3786
+ while (split > 1 && isFile(words[split - 1])) split--;
3787
+ if (split === words.length && command.data == null && words.length > 1) {
3788
+ const program = words.slice(0, -1).join(" ");
3789
+ const last = words[words.length - 1];
3790
+ if (this.bracesBalanced(program) && !/[{}]/.test(last)) {
3791
+ split = words.length - 1;
2936
3792
  }
2937
3793
  }
2938
- const filePath = rest.trim() || null;
2939
- return { program, filePath, fieldSep };
3794
+ return {
3795
+ program: words.slice(0, split).join(" "),
3796
+ filePaths: words.slice(split).map((w) => splitArgs(w).join(" ")),
3797
+ fieldSep
3798
+ };
3799
+ }
3800
+ bracesBalanced(text) {
3801
+ let depth = 0;
3802
+ for (const ch of text) {
3803
+ if (ch === "{") depth++;
3804
+ else if (ch === "}" && --depth < 0) return false;
3805
+ }
3806
+ return depth === 0;
2940
3807
  }
2941
3808
  parseProgram(program) {
2942
3809
  const rules = [];
@@ -3310,8 +4177,26 @@ var CliDiffCommandProcessor = class {
3310
4177
  }
3311
4178
  ];
3312
4179
  }
4180
+ writeDescription(context) {
4181
+ const t = context.translator;
4182
+ writeWrapped(
4183
+ context,
4184
+ t.t("cli.diff.help.compare_two_files_line", "Compare two files line by line and show what changed: lines starting with < (or -) are only in the first file, lines starting with > (or +) only in the second.")
4185
+ );
4186
+ context.writer.writeln();
4187
+ writeUsage(context, [
4188
+ ["diff <file1> <file2>", t.t("cli.diff.help.show_differences", "Show the differences")],
4189
+ ["diff -u <file1> <file2>", t.t("cli.diff.help.unified_format_context", "Unified format with context")]
4190
+ ]);
4191
+ context.writer.writeln();
4192
+ writeExamples(context, [
4193
+ ["diff old.txt new.txt", t.t("cli.diff.help.what_changed_between_versions", "What changed between versions")],
4194
+ ["diff -u config.bak config.txt", t.t("cli.diff.help.review_edits_git_style", "Review edits, git style")],
4195
+ ["diff -i a.txt b.txt", t.t("cli.diff.help.ignore_upper_lower_case", "Ignore upper/lower case")]
4196
+ ]);
4197
+ }
3313
4198
  async processCommand(command, context) {
3314
- const fs = context.services.get(
4199
+ const fs = context.services.getRequired(
3315
4200
  IFileSystemService_TOKEN
3316
4201
  );
3317
4202
  const unified = command.args["unified"] || command.args["u"] || false;
@@ -3474,13 +4359,13 @@ var CliDiffCommandProcessor = class {
3474
4359
  }
3475
4360
  parsePaths(command) {
3476
4361
  const raw = command.value || "";
3477
- return raw.split(/\s+/).filter(Boolean);
4362
+ return splitArgs(raw).filter(Boolean);
3478
4363
  }
3479
4364
  };
3480
4365
  var CliTeeCommandProcessor = class {
3481
4366
  constructor() {
3482
4367
  this.command = "tee";
3483
- this.description = "Read from a file and write to output files and stdout";
4368
+ this.description = "Copy piped input to files and to the output";
3484
4369
  this.author = DefaultLibraryAuthor;
3485
4370
  this.version = LIBRARY_VERSION;
3486
4371
  this.acceptsRawInput = true;
@@ -3496,72 +4381,56 @@ var CliTeeCommandProcessor = class {
3496
4381
  }
3497
4382
  ];
3498
4383
  }
4384
+ writeDescription(context) {
4385
+ const t = context.translator;
4386
+ writeWrapped(
4387
+ context,
4388
+ t.t("cli.tee.help.pass_piped_input_through", "Pass piped input through unchanged and save a copy of it to every file named, overwriting them or appending with -a. Like Unix tee, it reads only piped input: to copy a file, pipe it in with cat.")
4389
+ );
4390
+ context.writer.writeln();
4391
+ writeUsage(context, [
4392
+ ["<command> | tee <file...>", t.t("cli.tee.help.show_piped_output_save", "Show piped output and save it")],
4393
+ ["<command> | tee -a <file>", t.t("cli.tee.help.append_instead_overwriting", "Append instead of overwriting")]
4394
+ ]);
4395
+ context.writer.writeln();
4396
+ writeExamples(context, [
4397
+ ["cat notes.txt | tee copy.txt", t.t("cli.tee.help.show_save_copy", "Show and save a copy")],
4398
+ ["echo done | tee -a log.txt", t.t("cli.tee.help.add_line_log", "Add a line to a log")],
4399
+ [
4400
+ "cat source.txt | tee a.txt b.txt",
4401
+ t.t("cli.tee.help.copy_source_txt_txt", "Copy source.txt into a.txt and b.txt")
4402
+ ]
4403
+ ]);
4404
+ }
3499
4405
  async processCommand(command, context) {
3500
- const fs = context.services.get(
4406
+ const fs = context.services.getRequired(
3501
4407
  IFileSystemService_TOKEN
3502
4408
  );
3503
4409
  const append = command.args["append"] || command.args["a"] || false;
3504
- const parsed = this.parseArgs(command);
3505
- let content;
3506
- if (command.data != null) {
3507
- content = typeof command.data === "string" ? command.data : JSON.stringify(command.data);
3508
- const outputFiles = this.getAllPaths(command);
3509
- context.writer.writeln(content);
3510
- for (const outFile of outputFiles) {
3511
- if (append && fs.exists(outFile)) {
3512
- fs.writeFile(outFile, content, true);
3513
- } else {
3514
- fs.writeFile(outFile, content);
3515
- }
3516
- }
3517
- await fs.persist();
3518
- } else {
3519
- if (parsed.outputFiles.length === 0 && !parsed.inputFile) {
3520
- context.writer.writeError("tee: missing operand");
3521
- return;
3522
- }
3523
- if (!parsed.inputFile) {
3524
- context.writer.writeError("tee: missing input file");
3525
- return;
3526
- }
3527
- if (parsed.outputFiles.length === 0) {
3528
- context.writer.writeError("tee: missing output file");
3529
- return;
3530
- }
3531
- if (!fs.exists(parsed.inputFile)) {
3532
- context.writer.writeError(
3533
- `tee: ${parsed.inputFile}: No such file or directory`
3534
- );
3535
- return;
3536
- }
3537
- content = fs.readFile(parsed.inputFile) || "";
3538
- context.writer.writeln(content);
3539
- for (const outFile of parsed.outputFiles) {
3540
- if (append && fs.exists(outFile)) {
3541
- fs.writeFile(outFile, content, true);
3542
- } else {
3543
- fs.writeFile(outFile, content);
3544
- }
4410
+ if (command.data == null) {
4411
+ context.writer.writeError(
4412
+ "tee: no input: tee copies piped input to files and the output; use 'cat file | tee out.txt'"
4413
+ );
4414
+ context.process.exit(1, { silent: true });
4415
+ return;
4416
+ }
4417
+ const content = pipedText(command.data);
4418
+ context.writer.writeln(content);
4419
+ const fileText = content === "" || content.endsWith("\n") ? content : content + "\n";
4420
+ let changed = false;
4421
+ for (const outFile of this.getAllPaths(command)) {
4422
+ try {
4423
+ fs.writeFile(outFile, fileText, append);
4424
+ changed = true;
4425
+ } catch (e) {
4426
+ context.writer.writeError(`tee: ${e.message}`);
3545
4427
  }
3546
- await fs.persist();
3547
4428
  }
4429
+ if (changed) await fs.persist();
3548
4430
  }
3549
4431
  getAllPaths(command) {
3550
4432
  const raw = command.value || "";
3551
- return raw.split(/\s+/).filter(Boolean);
3552
- }
3553
- parseArgs(command) {
3554
- const raw = command.value || "";
3555
- const paths = raw.split(/\s+/).filter(Boolean);
3556
- if (paths.length < 2) {
3557
- return {
3558
- outputFiles: [],
3559
- inputFile: paths[0] || null
3560
- };
3561
- }
3562
- const inputFile = paths[paths.length - 1];
3563
- const outputFiles = paths.slice(0, paths.length - 1);
3564
- return { outputFiles, inputFile };
4433
+ return splitArgs(raw).filter(Boolean);
3565
4434
  }
3566
4435
  };
3567
4436
  var CliXargsCommandProcessor = class {
@@ -3590,65 +4459,91 @@ var CliXargsCommandProcessor = class {
3590
4459
  }
3591
4460
  ];
3592
4461
  }
4462
+ writeDescription(context) {
4463
+ const t = context.translator;
4464
+ writeWrapped(
4465
+ context,
4466
+ t.t("cli.xargs.help.run_command_arguments_taken", "Run a command with arguments taken from input, separated by blanks or newlines. By default all of them are appended to one command; -n runs it in batches and -I runs it once per line, replacing a placeholder. xargs options go before the command; everything after it, flags included, belongs to the command. Input is piped text or, without a pipe, the last file named.")
4467
+ );
4468
+ context.writer.writeln();
4469
+ writeUsage(context, [
4470
+ ["<command> | xargs <cmd>", t.t("cli.xargs.help.append_input_lines", "Append input lines to <cmd>")],
4471
+ ["<command> | xargs -I {} <cmd {}>", t.t("cli.xargs.help.run_once_per_line", "Run <cmd> once per line")],
4472
+ ["xargs <cmd> <file>", t.t("cli.xargs.help.read_arguments_file", "Read the arguments from a file")]
4473
+ ]);
4474
+ context.writer.writeln();
4475
+ writeExamples(context, [
4476
+ ['find . --name "*.tmp" | xargs rm', t.t("cli.xargs.help.delete_every_tmp_file", "Delete every .tmp file")],
4477
+ [
4478
+ "cat files.txt | xargs -I {} cp {} backup",
4479
+ t.t("cli.xargs.help.copy_each_listed_file", "Copy each listed file")
4480
+ ],
4481
+ ["cat dirs.txt | xargs -n 1 mkdir", t.t("cli.xargs.help.one_mkdir_per_line", "One mkdir per line")],
4482
+ ['find . --name "*.bak" | xargs rm -r', t.t("cli.xargs.help.inner_command_keeps_flags", "The inner command keeps its flags")]
4483
+ ]);
4484
+ }
3593
4485
  async processCommand(command, context) {
3594
- const fs = context.services.get(
4486
+ const fs = context.services.getRequired(
3595
4487
  IFileSystemService_TOKEN
3596
4488
  );
3597
- const replaceStr = command.args["replace"] || command.args["I"] || null;
3598
- const maxArgs = parseInt(
3599
- command.args["max-args"] || command.args["n"] || "0",
3600
- 10
3601
- );
3602
- const parsed = this.parseArgs(command);
4489
+ let parsed;
4490
+ try {
4491
+ parsed = command.rawCommand ? parseXargsLine(command.rawCommand) : {
4492
+ replace: stringArg(command.args["replace"] ?? command.args["I"]),
4493
+ maxArgs: parseInt(
4494
+ String(command.args["max-args"] ?? command.args["n"] ?? ""),
4495
+ 10
4496
+ ) || void 0,
4497
+ words: splitWords(command.value ?? "")
4498
+ };
4499
+ } catch (e) {
4500
+ context.writer.writeError(`xargs: ${e.message}`);
4501
+ return;
4502
+ }
4503
+ const replaceStr = parsed.replace;
4504
+ const maxArgs = parsed.maxArgs ?? 0;
3603
4505
  let commandTemplate;
3604
4506
  let argContent;
3605
4507
  if (command.data != null) {
3606
- commandTemplate = this.getAllNonFlagTokens(command).join(" ");
3607
- if (!commandTemplate) {
3608
- context.writer.writeError("xargs: missing command");
3609
- return;
3610
- }
3611
- argContent = typeof command.data === "string" ? command.data : JSON.stringify(command.data);
4508
+ commandTemplate = parsed.words.map((w) => w.raw).join(" ") || "echo";
4509
+ argContent = pipedText(command.data);
3612
4510
  } else {
3613
- if (!parsed.commandTemplate) {
4511
+ if (parsed.words.length === 0) {
3614
4512
  context.writer.writeError("xargs: missing command");
3615
4513
  return;
3616
4514
  }
3617
- commandTemplate = parsed.commandTemplate;
3618
- if (!parsed.inputFile) {
4515
+ if (parsed.words.length < 2) {
3619
4516
  context.writer.writeError("xargs: missing input file");
3620
4517
  return;
3621
4518
  }
3622
- if (!fs.exists(parsed.inputFile)) {
4519
+ const inputFile = parsed.words[parsed.words.length - 1].value;
4520
+ commandTemplate = parsed.words.slice(0, -1).map((w) => w.raw).join(" ");
4521
+ if (!fs.exists(inputFile)) {
3623
4522
  context.writer.writeError(
3624
- `xargs: ${parsed.inputFile}: No such file or directory`
4523
+ `xargs: ${inputFile}: No such file or directory`
3625
4524
  );
3626
4525
  return;
3627
4526
  }
3628
- argContent = fs.readFile(parsed.inputFile) || "";
4527
+ argContent = fs.readFile(inputFile) || "";
3629
4528
  }
3630
- const args = argContent.split("\n").map((l) => l.trim()).filter(Boolean);
3631
- if (args.length === 0) return;
3632
4529
  const commands = [];
3633
4530
  if (replaceStr) {
3634
- for (const arg of args) {
3635
- const cmd = commandTemplate.replace(
3636
- new RegExp(this.escapeRegex(replaceStr), "g"),
3637
- arg
4531
+ const lines = argContent.split("\n").map((l) => l.trim()).filter(Boolean);
4532
+ const placeholder = new RegExp(this.escapeRegex(replaceStr), "g");
4533
+ for (const line of lines) {
4534
+ commands.push(
4535
+ commandTemplate.replace(placeholder, () => quoteArg(line))
3638
4536
  );
3639
- commands.push(cmd);
3640
4537
  }
3641
- } else if (maxArgs > 0) {
3642
- for (let i = 0; i < args.length; i += maxArgs) {
3643
- const group = args.slice(i, i + maxArgs);
4538
+ } else {
4539
+ const args = splitArgs(argContent).map(quoteArg);
4540
+ if (args.length === 0) return;
4541
+ const size = maxArgs > 0 ? maxArgs : args.length;
4542
+ for (let i = 0; i < args.length; i += size) {
3644
4543
  commands.push(
3645
- `${commandTemplate} ${group.join(" ")}`
4544
+ `${commandTemplate} ${args.slice(i, i + size).join(" ")}`
3646
4545
  );
3647
4546
  }
3648
- } else {
3649
- commands.push(
3650
- `${commandTemplate} ${args.join(" ")}`
3651
- );
3652
4547
  }
3653
4548
  const executor = context.executor;
3654
4549
  for (const cmd of commands) {
@@ -3663,27 +4558,64 @@ var CliXargsCommandProcessor = class {
3663
4558
  }
3664
4559
  }
3665
4560
  }
3666
- getAllNonFlagTokens(command) {
3667
- const raw = command.value || "";
3668
- return raw.split(/\s+/).filter(Boolean);
3669
- }
3670
4561
  escapeRegex(str) {
3671
4562
  return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
3672
4563
  }
3673
- parseArgs(command) {
3674
- const raw = command.value || "";
3675
- const nonFlagTokens = raw.split(/\s+/).filter(Boolean);
3676
- if (nonFlagTokens.length < 2) {
3677
- return {
3678
- commandTemplate: nonFlagTokens[0] || null,
3679
- inputFile: null
3680
- };
4564
+ };
4565
+ function parseXargsLine(raw) {
4566
+ const words = splitWords(raw).slice(1);
4567
+ const result = { words: [] };
4568
+ let i = 0;
4569
+ const valueOf = (option, inline) => {
4570
+ if (inline !== "") return inline;
4571
+ if (i + 1 >= words.length) {
4572
+ throw new Error(`option requires an argument -- '${option}'`);
4573
+ }
4574
+ return words[++i].value;
4575
+ };
4576
+ for (; i < words.length; i++) {
4577
+ const word = words[i].raw;
4578
+ if (word === "--") {
4579
+ i++;
4580
+ break;
4581
+ }
4582
+ if (!word.startsWith("-") || word === "-") break;
4583
+ let match;
4584
+ if (match = word.match(/^(?:-I|--replace(?:=|$))(.*)$/)) {
4585
+ result.replace = valueOf("I", unquote(match[1]));
4586
+ } else if (word === "-i") {
4587
+ result.replace = "{}";
4588
+ } else if (match = word.match(/^(?:-n|--max-args(?:=|$))(.*)$/)) {
4589
+ const value = valueOf("n", unquote(match[1]));
4590
+ const count = parseInt(value, 10);
4591
+ if (isNaN(count) || count < 1) {
4592
+ throw new Error(`invalid number of arguments: '${value}'`);
4593
+ }
4594
+ result.maxArgs = count;
4595
+ } else {
4596
+ throw new Error(`invalid option -- '${word.replace(/^-+/, "")}'`);
3681
4597
  }
3682
- const inputFile = nonFlagTokens[nonFlagTokens.length - 1];
3683
- const commandTemplate = nonFlagTokens.slice(0, nonFlagTokens.length - 1).join(" ");
3684
- return { commandTemplate, inputFile };
3685
4598
  }
3686
- };
4599
+ result.words = words.slice(i);
4600
+ return result;
4601
+ }
4602
+ function splitWords(raw) {
4603
+ const words = [];
4604
+ const regex = /(?:"(?:[^"\\]|\\.)*"|'[^']*'|[^\s"']|["'])+/g;
4605
+ for (const match of raw.matchAll(regex)) {
4606
+ words.push({ raw: match[0], value: splitArgs(match[0]).join(" ") });
4607
+ }
4608
+ return words;
4609
+ }
4610
+ function unquote(value) {
4611
+ return splitArgs(value).join(" ");
4612
+ }
4613
+ function stringArg(value) {
4614
+ return value == null || value === true || value === "" ? void 0 : String(value);
4615
+ }
4616
+ function quoteArg(arg) {
4617
+ return quoteShellWord(arg);
4618
+ }
3687
4619
  var CliShCommandProcessor = class {
3688
4620
  constructor() {
3689
4621
  this.command = "sh";
@@ -3704,17 +4636,14 @@ var CliShCommandProcessor = class {
3704
4636
  ];
3705
4637
  }
3706
4638
  async processCommand(command, context) {
3707
- const fs = context.services.get(
4639
+ const fs = context.services.getRequired(
3708
4640
  IFileSystemService_TOKEN
3709
4641
  );
3710
- let filePath = (command.value || "").trim();
3711
- if (!filePath && command.rawCommand) {
3712
- const raw = command.rawCommand.trim();
3713
- const firstSpace = raw.indexOf(" ");
3714
- if (firstSpace > -1) {
3715
- filePath = raw.slice(firstSpace + 1).trim();
3716
- }
4642
+ let words = splitArgs(command.value || "");
4643
+ if (words.length === 0 && command.rawCommand) {
4644
+ words = splitArgs(command.rawCommand).slice(1).filter((w) => !w.startsWith("-"));
3717
4645
  }
4646
+ const filePath = words[0] ?? "";
3718
4647
  if (!filePath) {
3719
4648
  context.writer.writeError("sh: missing file operand");
3720
4649
  context.process.exit(1, { silent: true });
@@ -3747,10 +4676,10 @@ var CliShCommandProcessor = class {
3747
4676
  await executeScript(content, context);
3748
4677
  }
3749
4678
  writeDescription(context) {
3750
- const { writer } = context;
3751
- writer.writeln(this.description);
4679
+ const { writer, translator: t } = context;
4680
+ writer.writeln(t.t("cli.sh.description", this.description));
3752
4681
  writer.writeln();
3753
- writer.writeln("Usage:");
4682
+ writer.writeln(t.t("cli.common.usage", "Usage:"));
3754
4683
  writer.writeln(
3755
4684
  ` ${writer.wrapInColor("sh <script.sh>", CliForegroundColor.Cyan)}`
3756
4685
  );
@@ -3761,83 +4690,57 @@ var CliShCommandProcessor = class {
3761
4690
  ` ${writer.wrapInColor(". <script.sh>", CliForegroundColor.Cyan)}`
3762
4691
  );
3763
4692
  writer.writeln(
3764
- ` ${writer.wrapInColor("./script.sh", CliForegroundColor.Cyan)} (requires execute permission)`
4693
+ ` ${writer.wrapInColor("./script.sh", CliForegroundColor.Cyan)} ${t.t("cli.sh.help.requires_execute", "(requires execute permission)")}`
3765
4694
  );
3766
4695
  writer.writeln();
3767
- writer.writeln("Script syntax:");
3768
- writer.writeln(" # Lines starting with # are comments");
3769
- writer.writeln(" VAR=value # Variable assignment");
3770
- writer.writeln(" echo $VAR # Variable substitution");
3771
- writer.writeln(" set -e # Stop on error (default)");
3772
- writer.writeln(" set +e # Continue on errors");
4696
+ writer.writeln(t.t("cli.sh.help.script_syntax", "Script syntax:"));
4697
+ writer.writeln(` # ${t.t("cli.sh.help.comments", "Lines starting with # are comments")}`);
4698
+ writer.writeln(` cmd1; cmd2 # ${t.t("cli.sh.help.several_commands", "Several commands on one line")}`);
4699
+ writer.writeln(` VAR=value # ${t.t("cli.sh.help.assignment", "Variable assignment")}`);
4700
+ writer.writeln(` echo $VAR # ${t.t("cli.sh.help.substitution", "Variable substitution")}`);
4701
+ writer.writeln(` set -e # ${t.t("cli.sh.help.stop_on_error", "Stop on error (default)")}`);
4702
+ writer.writeln(` set +e # ${t.t("cli.sh.help.continue_on_errors", "Continue on errors")}`);
4703
+ writer.writeln(` exit [n] # ${t.t("cli.sh.help.exit", "Stop the script with status n")}`);
3773
4704
  writer.writeln();
3774
- writer.writeln("Example script:");
4705
+ writer.writeln(t.t("cli.sh.help.control_flow", "Control flow:"));
4706
+ writer.writeln(" if cmd; then ...; elif cmd; then ...; else ...; fi");
4707
+ writer.writeln(" for NAME in a b *.txt; do ...; done");
4708
+ writer.writeln(` while cmd; do ...; done ${t.t("cli.sh.help.until", "(until cmd: the opposite)")}`);
4709
+ writer.writeln(` break / continue [n] # ${t.t("cli.sh.help.break_continue", "Leave / skip in a loop")}`);
4710
+ writer.writeln(` ! cmd # ${t.t("cli.sh.help.negate", "Negate the exit status")}`);
4711
+ writer.writeln(
4712
+ ` ${t.t("cli.sh.help.conditions", "Conditions are commands:")} ${writer.wrapInColor("[ -f file ]", CliForegroundColor.Cyan)}, ${writer.wrapInColor('test "$A" = b', CliForegroundColor.Cyan)}, ${writer.wrapInColor("true", CliForegroundColor.Cyan)}, ${writer.wrapInColor("grep x file", CliForegroundColor.Cyan)}`
4713
+ );
4714
+ writer.writeln(` ${t.t("cli.sh.help.conditions_status", "(status 0 = true; see help test). Loops stop after 10000 iterations")}`);
4715
+ writer.writeln(` ${t.t("cli.sh.help.conditions_loops", "of while/until; Ctrl+C stops the script.")}`);
4716
+ writer.writeln();
4717
+ writer.writeln(t.t("cli.sh.help.example_script", "Example script:"));
3775
4718
  writer.writeln(
3776
4719
  writer.wrapInColor(
3777
- " #!/bin/sh\n # Setup script\n NAME=world\n echo Hello $NAME\n mkdir -p /tmp/test\n echo done > /tmp/test/status.txt",
4720
+ " #!/bin/sh\n # Setup script\n NAME=world\n echo Hello $NAME\n mkdir -p /tmp/test\n for f in a b c; do\n echo $f > /tmp/test/$f.txt\n done\n if [ -f /tmp/test/a.txt ]; then\n echo done > /tmp/test/status.txt\n fi",
3778
4721
  CliForegroundColor.Green
3779
4722
  )
3780
4723
  );
3781
4724
  }
3782
4725
  };
4726
+ var SCRIPT_RUNNER_TOKEN = "cli-script-runner";
3783
4727
  async function executeScript(content, context) {
3784
- const lines = content.split("\n");
3785
- const variables = {};
3786
- let stopOnError = true;
3787
- for (const rawLine of lines) {
3788
- const line = rawLine.trim();
3789
- if (!line || line.startsWith("#")) {
3790
- continue;
3791
- }
3792
- if (line === "set -e") {
3793
- stopOnError = true;
3794
- continue;
3795
- }
3796
- if (line === "set +e") {
3797
- stopOnError = false;
3798
- continue;
3799
- }
3800
- const assignMatch = line.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
3801
- if (assignMatch) {
3802
- const varName = assignMatch[1];
3803
- let varValue = assignMatch[2];
3804
- if (varValue.startsWith('"') && varValue.endsWith('"') || varValue.startsWith("'") && varValue.endsWith("'")) {
3805
- varValue = varValue.slice(1, -1);
3806
- }
3807
- varValue = substituteVariables(varValue, variables);
3808
- variables[varName] = varValue;
3809
- continue;
3810
- }
3811
- const expanded = substituteVariables(line, variables);
3812
- await context.executor.executeCommand(expanded, context);
3813
- const exitCode = context.process.exitCode;
3814
- if (stopOnError && exitCode !== void 0 && exitCode !== 0) {
3815
- context.writer.writeError(
3816
- `sh: script aborted at line: ${line}`
3817
- );
3818
- return;
3819
- }
4728
+ const runner = context.services.get(SCRIPT_RUNNER_TOKEN);
4729
+ if (!runner) {
4730
+ context.writer.writeError("sh: script runner not available (update @qodalis/cli)");
4731
+ context.process.exit(1, { silent: true });
4732
+ return;
3820
4733
  }
4734
+ await runner.run(content, context, { name: "sh" });
3821
4735
  }
3822
- function substituteVariables(input, variables) {
3823
- let result = input.replace(
3824
- /\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g,
3825
- (_, name) => variables[name] ?? ""
3826
- );
3827
- result = result.replace(
3828
- /\$([A-Za-z_][A-Za-z0-9_]*)/g,
3829
- (_, name) => variables[name] ?? ""
3830
- );
3831
- return result;
3832
- }
3833
-
3834
- // src/lib/services/indexed-db-file-system.service.ts
3835
4736
  var DB_NAME = "qodalis-cli-filesystem";
3836
4737
  var STORE_NAME = "filesystem";
3837
4738
  var ROOT_KEY = "root";
3838
4739
  var CWD_KEY = "cwd";
3839
4740
  var HOME_KEY = "home";
3840
4741
  var DEFAULT_HOME = "/";
4742
+ var SYSTEM_OWNERSHIP = { uid: "root", gid: "admin" };
4743
+ var ANONYMOUS_OWNERSHIP = { uid: "nobody", gid: "nogroup" };
3841
4744
  function createSeedFileSystem() {
3842
4745
  const now = Date.now();
3843
4746
  return {
@@ -3885,6 +4788,88 @@ var IndexedDbFileSystemService = class {
3885
4788
  this.homePath = DEFAULT_HOME;
3886
4789
  this.currentUid = null;
3887
4790
  this.currentGroups = [];
4791
+ this.systemDepth = 0;
4792
+ }
4793
+ // --- Access control ---
4794
+ setAccessPolicy(policy) {
4795
+ this.accessPolicy = policy;
4796
+ }
4797
+ runAsSystem(fn) {
4798
+ this.systemDepth++;
4799
+ try {
4800
+ return fn();
4801
+ } finally {
4802
+ this.systemDepth--;
4803
+ }
4804
+ }
4805
+ reassignOwner(fromUid, toUid) {
4806
+ const from = fromUid.toLowerCase();
4807
+ if (!from || !toUid) return 0;
4808
+ let changed = 0;
4809
+ const visit = (node) => {
4810
+ if (node.ownership && node.ownership.uid.toLowerCase() === from) {
4811
+ node.ownership = { ...node.ownership, uid: toUid };
4812
+ changed++;
4813
+ }
4814
+ node.children?.forEach(visit);
4815
+ };
4816
+ visit(this.root);
4817
+ return changed;
4818
+ }
4819
+ canTraverse(path) {
4820
+ try {
4821
+ this.assertTraverse(this.resolvePath(path), "stat", path);
4822
+ return true;
4823
+ } catch {
4824
+ return false;
4825
+ }
4826
+ }
4827
+ get enforcing() {
4828
+ return !!this.accessPolicy && this.systemDepth === 0;
4829
+ }
4830
+ deny(op, path, reason = "Permission denied") {
4831
+ throw new CliAccessDeniedError(`${op}: ${path}: ${reason}`);
4832
+ }
4833
+ /** `x` on every existing directory on the way to `resolved` (excluding the target). */
4834
+ assertTraverse(resolved, op, display) {
4835
+ if (!this.enforcing) return;
4836
+ const parts = resolved.split("/").filter(Boolean);
4837
+ let current = this.root;
4838
+ for (let i = 0; i < parts.length; i++) {
4839
+ if (!current || current.type !== "directory") return;
4840
+ if (!this.accessPolicy.canAccess(current, "execute")) this.deny(op, display);
4841
+ current = current.children?.find((c) => c.name === parts[i]);
4842
+ }
4843
+ }
4844
+ assertAccess(node, action, op, display) {
4845
+ if (this.enforcing && !this.accessPolicy.canAccess(node, action)) this.deny(op, display);
4846
+ }
4847
+ /** Recursive delete: every directory inside must allow removing its entries (`w` + `x`, readable). */
4848
+ assertRemovableTree(dir, dirPath, display) {
4849
+ if (!this.enforcing) return;
4850
+ this.assertAccess(dir, "read", "rm", display);
4851
+ this.assertAccess(dir, "execute", "rm", display);
4852
+ if ((dir.children?.length ?? 0) > 0) this.assertAccess(dir, "write", "rm", display);
4853
+ for (const child of dir.children ?? []) {
4854
+ if (child.type === "directory") this.assertRemovableTree(child, `${dirPath}/${child.name}`, display);
4855
+ }
4856
+ }
4857
+ /** Copying reads everything it copies: `r` on every file, `r` + `x` on every directory. */
4858
+ assertReadableTree(node, display) {
4859
+ if (!this.enforcing) return;
4860
+ this.assertAccess(node, "read", "cp", display);
4861
+ if (node.type !== "directory") return;
4862
+ this.assertAccess(node, "execute", "cp", display);
4863
+ for (const child of node.children ?? []) {
4864
+ this.assertReadableTree(child, `${display.replace(/\/+$/, "")}/${child.name}`);
4865
+ }
4866
+ }
4867
+ /** Creating/removing entries needs `w` and `x` on the containing directory. */
4868
+ assertModifyDir(dir, dirPath, op, display) {
4869
+ if (!this.enforcing) return;
4870
+ this.assertTraverse(dirPath, op, display);
4871
+ this.assertAccess(dir, "execute", op, display);
4872
+ this.assertAccess(dir, "write", op, display);
3888
4873
  }
3889
4874
  // --- User context ---
3890
4875
  setCurrentUser(uid, groups) {
@@ -3892,7 +4877,9 @@ var IndexedDbFileSystemService = class {
3892
4877
  this.currentGroups = groups;
3893
4878
  }
3894
4879
  getDefaultOwnership() {
3895
- if (!this.currentUid) return void 0;
4880
+ if (!this.currentUid) {
4881
+ return this.accessPolicy ? { ...ANONYMOUS_OWNERSHIP } : void 0;
4882
+ }
3896
4883
  return {
3897
4884
  uid: this.currentUid,
3898
4885
  gid: this.currentGroups[0] || "users"
@@ -3911,6 +4898,8 @@ var IndexedDbFileSystemService = class {
3911
4898
  if (node.type !== "directory") {
3912
4899
  throw new Error(`cd: ${path}: Not a directory`);
3913
4900
  }
4901
+ this.assertTraverse(resolved, "cd", path);
4902
+ this.assertAccess(node, "execute", "cd", path);
3914
4903
  this.cwd = resolved;
3915
4904
  }
3916
4905
  getHomePath() {
@@ -3967,6 +4956,8 @@ var IndexedDbFileSystemService = class {
3967
4956
  if (node.type !== "directory") {
3968
4957
  throw new Error(`ls: ${path}: Not a directory`);
3969
4958
  }
4959
+ this.assertTraverse(this.resolvePath(path), "ls", path);
4960
+ this.assertAccess(node, "read", "ls", path);
3970
4961
  return node.children || [];
3971
4962
  }
3972
4963
  readFile(path) {
@@ -3977,6 +4968,8 @@ var IndexedDbFileSystemService = class {
3977
4968
  if (node.type === "directory") {
3978
4969
  throw new Error(`cat: ${path}: Is a directory`);
3979
4970
  }
4971
+ this.assertTraverse(this.resolvePath(path), "cat", path);
4972
+ this.assertAccess(node, "read", "cat", path);
3980
4973
  return node.content ?? "";
3981
4974
  }
3982
4975
  exists(path) {
@@ -4001,6 +4994,10 @@ var IndexedDbFileSystemService = class {
4001
4994
  }
4002
4995
  let child = current.children.find((c) => c.name === part);
4003
4996
  if (!child) {
4997
+ if (this.enforcing) {
4998
+ this.assertAccess(current, "execute", "mkdir", path);
4999
+ this.assertAccess(current, "write", "mkdir", path);
5000
+ }
4004
5001
  const now = Date.now();
4005
5002
  child = {
4006
5003
  name: part,
@@ -4016,6 +5013,8 @@ var IndexedDbFileSystemService = class {
4016
5013
  current.modifiedAt = now;
4017
5014
  } else if (child.type !== "directory") {
4018
5015
  throw new Error(`mkdir: ${part}: Not a directory`);
5016
+ } else {
5017
+ this.assertAccess(child, "execute", "mkdir", path);
4019
5018
  }
4020
5019
  current = child;
4021
5020
  }
@@ -4034,6 +5033,7 @@ var IndexedDbFileSystemService = class {
4034
5033
  if (parent.children?.find((c) => c.name === dirName)) {
4035
5034
  throw new Error(`mkdir: ${path}: File exists`);
4036
5035
  }
5036
+ this.assertModifyDir(parent, parentPath, "mkdir", path);
4037
5037
  const now = Date.now();
4038
5038
  if (!parent.children) {
4039
5039
  parent.children = [];
@@ -4068,6 +5068,12 @@ var IndexedDbFileSystemService = class {
4068
5068
  }
4069
5069
  const existing = parent.children?.find((c) => c.name === fileName);
4070
5070
  const now = Date.now();
5071
+ if (existing) {
5072
+ this.assertTraverse(resolved, "touch", path);
5073
+ this.assertAccess(existing, "write", "touch", path);
5074
+ } else {
5075
+ this.assertModifyDir(parent, parentPath, "touch", path);
5076
+ }
4071
5077
  if (existing) {
4072
5078
  existing.modifiedAt = now;
4073
5079
  } else {
@@ -4094,6 +5100,8 @@ var IndexedDbFileSystemService = class {
4094
5100
  if (node.type === "directory") {
4095
5101
  throw new Error(`write: ${path}: Is a directory`);
4096
5102
  }
5103
+ this.assertTraverse(resolved, "write", path);
5104
+ this.assertAccess(node, "write", "write", path);
4097
5105
  const now = Date.now();
4098
5106
  node.content = append ? (node.content ?? "") + content : content;
4099
5107
  node.size = new Blob([node.content]).size;
@@ -4122,6 +5130,8 @@ var IndexedDbFileSystemService = class {
4122
5130
  if (target.type === "directory" && !recursive) {
4123
5131
  throw new Error(`rm: ${path}: Is a directory`);
4124
5132
  }
5133
+ this.assertModifyDir(parent, parentPath, "rm", path);
5134
+ if (target.type === "directory") this.assertRemovableTree(target, resolved, path);
4125
5135
  parent.children.splice(index, 1);
4126
5136
  parent.modifiedAt = Date.now();
4127
5137
  }
@@ -4136,10 +5146,13 @@ var IndexedDbFileSystemService = class {
4136
5146
  `cp: -r not specified; omitting directory '${src}'`
4137
5147
  );
4138
5148
  }
5149
+ this.assertTraverse(srcResolved, "cp", src);
5150
+ this.assertReadableTree(srcNode, src);
4139
5151
  const destResolved = this.resolvePath(dest);
4140
5152
  const destNode = this.getNode(destResolved);
4141
5153
  if (destNode && destNode.type === "directory") {
4142
- const clone = this.cloneNode(srcNode);
5154
+ this.assertModifyDir(destNode, destResolved, "cp", dest);
5155
+ const clone = this.ownedByCurrentUser(this.cloneNode(srcNode));
4143
5156
  if (!destNode.children) {
4144
5157
  destNode.children = [];
4145
5158
  }
@@ -4159,7 +5172,8 @@ var IndexedDbFileSystemService = class {
4159
5172
  if (!parent || parent.type !== "directory") {
4160
5173
  throw new Error(`cp: ${parentPath}: No such file or directory`);
4161
5174
  }
4162
- const clone = this.cloneNode(srcNode);
5175
+ this.assertModifyDir(parent, parentPath, "cp", dest);
5176
+ const clone = this.ownedByCurrentUser(this.cloneNode(srcNode));
4163
5177
  clone.name = newName;
4164
5178
  if (!parent.children) {
4165
5179
  parent.children = [];
@@ -4185,16 +5199,23 @@ var IndexedDbFileSystemService = class {
4185
5199
  }
4186
5200
  const destResolved = this.resolvePath(dest);
4187
5201
  const destNode = this.getNode(destResolved);
5202
+ if (srcNode.type === "directory" && (destResolved === srcResolved || destResolved.startsWith(srcResolved + "/"))) {
5203
+ throw new Error(
5204
+ `mv: cannot move '${src}' to a subdirectory of itself, '${dest}'`
5205
+ );
5206
+ }
4188
5207
  const srcParts = srcResolved.split("/").filter(Boolean);
4189
5208
  const srcParentPath = "/" + srcParts.slice(0, -1).join("/");
4190
5209
  const srcParent = this.getNode(srcParentPath);
4191
5210
  if (!srcParent || !srcParent.children) {
4192
5211
  throw new Error(`mv: ${src}: No such file or directory`);
4193
5212
  }
4194
- const srcIndex = srcParent.children.findIndex(
4195
- (c) => c.name === srcNode.name
4196
- );
5213
+ this.assertModifyDir(srcParent, srcParentPath, "mv", src);
5214
+ if (destResolved === srcResolved || destNode && destNode.type === "directory" && destNode === srcParent) {
5215
+ throw new Error(`mv: '${src}' and '${dest}' are the same file`);
5216
+ }
4197
5217
  if (destNode && destNode.type === "directory") {
5218
+ this.assertModifyDir(destNode, destResolved, "mv", dest);
4198
5219
  if (!destNode.children) {
4199
5220
  destNode.children = [];
4200
5221
  }
@@ -4204,7 +5225,7 @@ var IndexedDbFileSystemService = class {
4204
5225
  if (existingIdx !== -1) {
4205
5226
  destNode.children.splice(existingIdx, 1);
4206
5227
  }
4207
- srcParent.children.splice(srcIndex, 1);
5228
+ srcParent.children.splice(srcParent.children.indexOf(srcNode), 1);
4208
5229
  destNode.children.push(srcNode);
4209
5230
  destNode.modifiedAt = Date.now();
4210
5231
  } else {
@@ -4217,7 +5238,8 @@ var IndexedDbFileSystemService = class {
4217
5238
  `mv: ${destParentPath}: No such file or directory`
4218
5239
  );
4219
5240
  }
4220
- srcParent.children.splice(srcIndex, 1);
5241
+ this.assertModifyDir(destParent, destParentPath, "mv", dest);
5242
+ srcParent.children.splice(srcParent.children.indexOf(srcNode), 1);
4221
5243
  srcNode.name = newName;
4222
5244
  if (!destParent.children) {
4223
5245
  destParent.children = [];
@@ -4240,6 +5262,9 @@ var IndexedDbFileSystemService = class {
4240
5262
  if (!node) {
4241
5263
  throw new Error(`chmod: ${path}: No such file or directory`);
4242
5264
  }
5265
+ if (this.enforcing && !this.accessPolicy.canChangeOwnership(node)) {
5266
+ this.deny("chmod", path, "Operation not permitted");
5267
+ }
4243
5268
  node.permissions = permissions;
4244
5269
  }
4245
5270
  chown(path, ownership) {
@@ -4248,6 +5273,9 @@ var IndexedDbFileSystemService = class {
4248
5273
  if (!node) {
4249
5274
  throw new Error(`chown: ${path}: No such file or directory`);
4250
5275
  }
5276
+ if (this.enforcing && !this.accessPolicy.canChangeOwnership(node, ownership)) {
5277
+ this.deny("chown", path, "Operation not permitted");
5278
+ }
4251
5279
  node.ownership = ownership;
4252
5280
  }
4253
5281
  // --- Persistence ---
@@ -4278,8 +5306,10 @@ var IndexedDbFileSystemService = class {
4278
5306
  const tx = db.transaction(STORE_NAME, "readwrite");
4279
5307
  const store = tx.objectStore(STORE_NAME);
4280
5308
  store.put(this.root, ROOT_KEY);
4281
- store.put(this.cwd, CWD_KEY);
4282
- store.put(this.homePath, HOME_KEY);
5309
+ if (!this.currentUid) {
5310
+ store.put(this.cwd, CWD_KEY);
5311
+ store.put(this.homePath, HOME_KEY);
5312
+ }
4283
5313
  await new Promise((resolve, reject) => {
4284
5314
  tx.oncomplete = () => resolve();
4285
5315
  tx.onerror = () => reject(tx.error);
@@ -4289,6 +5319,18 @@ var IndexedDbFileSystemService = class {
4289
5319
  }
4290
5320
  }
4291
5321
  // --- Private helpers ---
5322
+ /** A copy belongs to whoever made it (like `cp` without `-p`) */
5323
+ ownedByCurrentUser(node) {
5324
+ const owner = this.getDefaultOwnership();
5325
+ if (owner) {
5326
+ const apply = (n) => {
5327
+ n.ownership = { ...owner };
5328
+ n.children?.forEach(apply);
5329
+ };
5330
+ apply(node);
5331
+ }
5332
+ return node;
5333
+ }
4292
5334
  cloneNode(node) {
4293
5335
  const now = Date.now();
4294
5336
  const clone = {
@@ -4330,8 +5372,6 @@ var IndexedDbFileSystemService = class {
4330
5372
  });
4331
5373
  }
4332
5374
  };
4333
-
4334
- // src/lib/services/server-file-system.service.ts
4335
5375
  var DEFAULT_HOME2 = "/home/user";
4336
5376
  function createSeedFileSystem2() {
4337
5377
  const now = Date.now();
@@ -4381,6 +5421,7 @@ var ServerFileSystemService = class {
4381
5421
  this.currentUid = null;
4382
5422
  this.currentGroups = [];
4383
5423
  this.baseUrl = options.baseUrl.replace(/\/+$/, "");
5424
+ this.headersProvider = options.headers;
4384
5425
  }
4385
5426
  // --- User context ---
4386
5427
  setCurrentUser(uid, groups) {
@@ -4697,15 +5738,20 @@ var ServerFileSystemService = class {
4697
5738
  }
4698
5739
  const destResolved = this.resolvePath(dest);
4699
5740
  const destNode = this.getNode(destResolved);
5741
+ if (srcNode.type === "directory" && (destResolved === srcResolved || destResolved.startsWith(srcResolved + "/"))) {
5742
+ throw new Error(
5743
+ `mv: cannot move '${src}' to a subdirectory of itself, '${dest}'`
5744
+ );
5745
+ }
4700
5746
  const srcParts = srcResolved.split("/").filter(Boolean);
4701
5747
  const srcParentPath = "/" + srcParts.slice(0, -1).join("/");
4702
5748
  const srcParent = this.getNode(srcParentPath);
4703
5749
  if (!srcParent || !srcParent.children) {
4704
5750
  throw new Error(`mv: ${src}: No such file or directory`);
4705
5751
  }
4706
- const srcIndex = srcParent.children.findIndex(
4707
- (c) => c.name === srcNode.name
4708
- );
5752
+ if (destResolved === srcResolved || destNode && destNode.type === "directory" && destNode === srcParent) {
5753
+ throw new Error(`mv: '${src}' and '${dest}' are the same file`);
5754
+ }
4709
5755
  if (destNode && destNode.type === "directory") {
4710
5756
  if (!destNode.children) {
4711
5757
  destNode.children = [];
@@ -4716,7 +5762,7 @@ var ServerFileSystemService = class {
4716
5762
  if (existingIdx !== -1) {
4717
5763
  destNode.children.splice(existingIdx, 1);
4718
5764
  }
4719
- srcParent.children.splice(srcIndex, 1);
5765
+ srcParent.children.splice(srcParent.children.indexOf(srcNode), 1);
4720
5766
  destNode.children.push(srcNode);
4721
5767
  destNode.modifiedAt = Date.now();
4722
5768
  } else {
@@ -4729,7 +5775,7 @@ var ServerFileSystemService = class {
4729
5775
  `mv: ${destParentPath}: No such file or directory`
4730
5776
  );
4731
5777
  }
4732
- srcParent.children.splice(srcIndex, 1);
5778
+ srcParent.children.splice(srcParent.children.indexOf(srcNode), 1);
4733
5779
  srcNode.name = newName;
4734
5780
  if (!destParent.children) {
4735
5781
  destParent.children = [];
@@ -4787,8 +5833,15 @@ var ServerFileSystemService = class {
4787
5833
  }
4788
5834
  // --- Private: server communication ---
4789
5835
  async serverFetch(endpoint, options) {
4790
- const url = `${this.baseUrl}/api/cli/fs${endpoint}`;
4791
- const response = await fetch(url, options);
5836
+ const url = `${this.baseUrl}/api/qcli/fs${endpoint}`;
5837
+ const mergedHeaders = {
5838
+ ...resolveHeaders(this.headersProvider),
5839
+ ...options?.headers ?? {}
5840
+ };
5841
+ const response = await fetch(url, {
5842
+ ...options,
5843
+ headers: mergedHeaders
5844
+ });
4792
5845
  if (!response.ok) {
4793
5846
  const body = await response.text();
4794
5847
  throw new Error(
@@ -4957,6 +6010,115 @@ var VirtualFsFileTransferService = class {
4957
6010
  return this._browserService.uploadFromBrowser(accept);
4958
6011
  }
4959
6012
  };
6013
+ var CliFilesPermissions = {
6014
+ /** Bypass all file permission checks */
6015
+ Admin: "files:admin",
6016
+ /** chmod/chown files you do not own */
6017
+ Chown: "files:chown"
6018
+ };
6019
+ var HOME_DIR_PERMISSIONS = "rwxr-x---";
6020
+ function authorization(context) {
6021
+ try {
6022
+ return context.services.get(ICliAuthorizationService_TOKEN);
6023
+ } catch {
6024
+ return void 0;
6025
+ }
6026
+ }
6027
+ function createFileAccessPolicy(context) {
6028
+ return {
6029
+ // Nodes without an owner (system seed, legacy data) belong to root:admin
6030
+ canAccess: (node, action) => cliCanAccess(
6031
+ context,
6032
+ node.ownership ? node : { ...node, ownership: SYSTEM_OWNERSHIP },
6033
+ action,
6034
+ [CliFilesPermissions.Admin]
6035
+ ),
6036
+ canChangeOwnership: (node, newOwnership) => {
6037
+ const auth = authorization(context);
6038
+ if (!auth) return true;
6039
+ if (auth.can(CliFilesPermissions.Admin) || auth.can(CliFilesPermissions.Chown)) return true;
6040
+ const principal = auth.getPrincipal();
6041
+ if (!principal) return false;
6042
+ const owns = !!node.ownership && (node.ownership.uid === principal.name || node.ownership.uid === principal.id);
6043
+ const keepsOwner = !newOwnership || newOwnership.uid === node.ownership?.uid || newOwnership.uid === principal.name;
6044
+ return owns && keepsOwner;
6045
+ }
6046
+ };
6047
+ }
6048
+ function followUserSession(context, fs) {
6049
+ let sessions;
6050
+ try {
6051
+ sessions = context.services.get(ICliUserSessionService_TOKEN);
6052
+ } catch {
6053
+ sessions = void 0;
6054
+ }
6055
+ if (!sessions) return void 0;
6056
+ let previousUser = null;
6057
+ return sessions.getUserSession().subscribe((session) => {
6058
+ if (!session?.user) {
6059
+ fs.setCurrentUser("", []);
6060
+ if (previousUser !== null || !canEnter(fs, fs.getCurrentDirectory())) {
6061
+ try {
6062
+ fs.setCurrentDirectory("/");
6063
+ void fs.persist();
6064
+ } catch {
6065
+ }
6066
+ }
6067
+ previousUser = null;
6068
+ return;
6069
+ }
6070
+ const user = session.user;
6071
+ fs.setCurrentUser(user.name, user.groups ?? []);
6072
+ if (user.homeDir) {
6073
+ const home = user.homeDir;
6074
+ const system = (fn) => fs.runAsSystem ? fs.runAsSystem(fn) : fn();
6075
+ if (!fs.exists(home)) {
6076
+ system(() => {
6077
+ fs.createDirectory(home, true);
6078
+ if (home !== "/") {
6079
+ fs.chown(home, { uid: user.name, gid: user.groups?.[0] ?? "users" });
6080
+ fs.chmod(home, HOME_DIR_PERMISSIONS);
6081
+ }
6082
+ });
6083
+ void fs.persist();
6084
+ }
6085
+ fs.setHomePath(home);
6086
+ if (previousUser !== user.id) {
6087
+ try {
6088
+ fs.setCurrentDirectory(home);
6089
+ } catch {
6090
+ }
6091
+ }
6092
+ }
6093
+ previousUser = user.id;
6094
+ });
6095
+ }
6096
+ function canEnter(fs, path) {
6097
+ try {
6098
+ fs.setCurrentDirectory(path);
6099
+ return true;
6100
+ } catch {
6101
+ return false;
6102
+ }
6103
+ }
6104
+ function followUserAccounts(context, fs) {
6105
+ let events;
6106
+ try {
6107
+ events = context.services.get(ICliUserEventService_TOKEN);
6108
+ } catch {
6109
+ events = void 0;
6110
+ }
6111
+ if (!events || !fs.reassignOwner) return void 0;
6112
+ return events.events$.subscribe((event) => {
6113
+ let changed = 0;
6114
+ if (event.type === "user_updated" && event.previousName && event.previousName !== event.user?.name) {
6115
+ changed = fs.reassignOwner(event.previousName, event.user.name);
6116
+ } else if (event.type === "user_deleted" && event.user?.name) {
6117
+ changed = fs.reassignOwner(event.user.name, SYSTEM_OWNERSHIP.uid);
6118
+ }
6119
+ if (changed > 0) void fs.persist().catch(() => void 0);
6120
+ });
6121
+ }
4960
6122
 
4961
6123
  // src/lib/completion/file-path-completion-provider.ts
4962
6124
  var FILE_COMMANDS = /* @__PURE__ */ new Set([
@@ -4977,7 +6139,26 @@ var FILE_COMMANDS = /* @__PURE__ */ new Set([
4977
6139
  "find",
4978
6140
  "head",
4979
6141
  "tail",
4980
- "wc"
6142
+ "wc",
6143
+ "sort",
6144
+ "uniq",
6145
+ "cut",
6146
+ "paste",
6147
+ "tac",
6148
+ "diff",
6149
+ "sed",
6150
+ "awk",
6151
+ "du",
6152
+ "stat",
6153
+ "chmod",
6154
+ "chown",
6155
+ "ln",
6156
+ "tee",
6157
+ "basename",
6158
+ "dirname",
6159
+ "sh",
6160
+ "source",
6161
+ "xargs"
4981
6162
  ]);
4982
6163
  var FilePathCompletionProvider = class {
4983
6164
  constructor(fs) {
@@ -4996,7 +6177,14 @@ var FilePathCompletionProvider = class {
4996
6177
  if (token.startsWith("-")) {
4997
6178
  return [];
4998
6179
  }
4999
- return this.completePath(token);
6180
+ const quote = token.startsWith('"') || token.startsWith("'") ? token[0] : "";
6181
+ const partial = quote ? token.slice(1).replace(new RegExp(`${quote}$`), "") : token;
6182
+ const paths = this.completePath(partial);
6183
+ if (!quote && !paths.some((p) => /\s/.test(p))) {
6184
+ return paths;
6185
+ }
6186
+ const q = quote || '"';
6187
+ return paths.map((p) => p.endsWith("/") ? `${q}${p}` : `${q}${p}${q}`);
5000
6188
  }
5001
6189
  completePath(partial) {
5002
6190
  let dirPath;
@@ -5039,6 +6227,75 @@ var FilePathCompletionProvider = class {
5039
6227
  return results.sort();
5040
6228
  }
5041
6229
  };
6230
+ var CliUploadCommandProcessor = class {
6231
+ constructor() {
6232
+ this.command = "upload";
6233
+ this.description = "Upload a file from your local machine into the virtual filesystem";
6234
+ this.author = DefaultLibraryAuthor;
6235
+ this.version = LIBRARY_VERSION;
6236
+ this.acceptsRawInput = true;
6237
+ this.metadata = { icon: "\u{1F4E4}", module: "file management" };
6238
+ this.parameters = [
6239
+ {
6240
+ name: "accept",
6241
+ description: 'File type filter (e.g. ".json,.txt" or "image/*")',
6242
+ required: false,
6243
+ type: "string"
6244
+ }
6245
+ ];
6246
+ }
6247
+ async processCommand(command, context) {
6248
+ const fs = context.services.getRequired(
6249
+ IFileSystemService_TOKEN
6250
+ );
6251
+ const fileService = context.services.getRequired(
6252
+ ICliFileTransferService_TOKEN
6253
+ );
6254
+ if (!fileService) {
6255
+ context.writer.writeError("File transfer service not available.");
6256
+ return;
6257
+ }
6258
+ const accept = command.args?.["accept"];
6259
+ const destPath = command.value?.trim() || void 0;
6260
+ context.spinner?.show("Waiting for file selection...");
6261
+ context.notifier.info("Waiting for file selection");
6262
+ const picked = await fileService.uploadFromBrowser(accept);
6263
+ if (!picked) {
6264
+ context.spinner?.hide();
6265
+ context.writer.writeln("Upload cancelled.");
6266
+ return;
6267
+ }
6268
+ const cwd = fs.getCurrentDirectory();
6269
+ const filename = destPath || picked.name;
6270
+ const resolved = fs.resolvePath(
6271
+ filename.startsWith("/") ? filename : cwd + "/" + filename
6272
+ );
6273
+ context.spinner?.show(`Saving "${picked.name}" (${picked.content.length} bytes)...`);
6274
+ context.notifier.info(`Saving ${picked.name}`);
6275
+ try {
6276
+ fs.writeFile(resolved, picked.content);
6277
+ await fs.persist();
6278
+ context.spinner?.hide();
6279
+ context.writer.writeSuccess(
6280
+ `Uploaded ${context.writer.wrapInColor(picked.name, CliForegroundColor.Cyan)} (${picked.content.length} bytes) \u2192 ${resolved}`
6281
+ );
6282
+ } catch (e) {
6283
+ context.spinner?.hide();
6284
+ context.writer.writeError(e.message || "Failed to write file");
6285
+ }
6286
+ }
6287
+ writeDescription(context) {
6288
+ context.writer.writeln(this.description);
6289
+ context.writer.writeln("");
6290
+ context.writer.writeln("Usage:");
6291
+ context.writer.writeln(" upload Open file picker, save to current directory");
6292
+ context.writer.writeln(" upload myfile.txt Open file picker, save as myfile.txt");
6293
+ context.writer.writeln(" upload /home/user/data Open file picker, save to absolute path");
6294
+ context.writer.writeln(' upload --accept=".json" Filter file picker to .json files');
6295
+ }
6296
+ };
6297
+
6298
+ // src/public-api.ts
5042
6299
  var fsService = new IndexedDbFileSystemService();
5043
6300
  var filesModule = {
5044
6301
  apiVersion: API_VERSION,
@@ -5079,7 +6336,12 @@ var filesModule = {
5079
6336
  new CliDiffCommandProcessor(),
5080
6337
  new CliTeeCommandProcessor(),
5081
6338
  new CliXargsCommandProcessor(),
5082
- new CliShCommandProcessor()
6339
+ new CliShCommandProcessor(),
6340
+ new CliUploadCommandProcessor()
6341
+ ],
6342
+ permissions: [
6343
+ { name: CliFilesPermissions.Admin, description: "Bypass file permission checks" },
6344
+ { name: CliFilesPermissions.Chown, description: "chmod/chown files owned by others" }
5083
6345
  ],
5084
6346
  services: [
5085
6347
  {
@@ -5112,7 +6374,7 @@ var filesModule = {
5112
6374
  return { ...this, config };
5113
6375
  },
5114
6376
  async onInit(context) {
5115
- const fs = context.services.get(
6377
+ const fs = context.services.getRequired(
5116
6378
  IFileSystemService_TOKEN
5117
6379
  );
5118
6380
  await fs.initialize();
@@ -5123,7 +6385,7 @@ var filesModule = {
5123
6385
  }
5124
6386
  let dragDrop;
5125
6387
  try {
5126
- dragDrop = context.services.get(ICliDragDropService_TOKEN);
6388
+ dragDrop = context.services.getRequired(ICliDragDropService_TOKEN);
5127
6389
  } catch {
5128
6390
  }
5129
6391
  if (dragDrop) {
@@ -5152,13 +6414,25 @@ var filesModule = {
5152
6414
  });
5153
6415
  }
5154
6416
  },
6417
+ async onAfterBoot(context) {
6418
+ const fs = context.services.getRequired(IFileSystemService_TOKEN);
6419
+ fs.setAccessPolicy?.(createFileAccessPolicy(context));
6420
+ this._sessionSubscription?.unsubscribe();
6421
+ this._sessionSubscription = followUserSession(context, fs);
6422
+ this._accountsSubscription?.unsubscribe();
6423
+ this._accountsSubscription = followUserAccounts(context, fs);
6424
+ },
5155
6425
  async onDestroy(_context) {
5156
6426
  const sub = this._dragDropSubscription;
5157
6427
  sub?.unsubscribe();
5158
6428
  this._dragDropSubscription = void 0;
6429
+ this._sessionSubscription?.unsubscribe();
6430
+ this._sessionSubscription = void 0;
6431
+ this._accountsSubscription?.unsubscribe();
6432
+ this._accountsSubscription = void 0;
5159
6433
  }
5160
6434
  };
5161
6435
 
5162
- export { CliAwkCommandProcessor, CliBasenameCommandProcessor, CliCatCommandProcessor, CliCdCommandProcessor, CliChmodCommandProcessor, CliChownCommandProcessor, CliCpCommandProcessor, CliCutCommandProcessor, CliDiffCommandProcessor, CliDirnameCommandProcessor, CliDuCommandProcessor, CliEchoCommandProcessor, CliFindCommandProcessor, CliGrepCommandProcessor, CliHeadCommandProcessor, CliLnCommandProcessor, CliLsCommandProcessor, CliMkdirCommandProcessor, CliMvCommandProcessor, CliPasteCommandProcessor, CliPwdCommandProcessor, CliRmCommandProcessor, CliRmdirCommandProcessor, CliSedCommandProcessor, CliShCommandProcessor, CliSortCommandProcessor, CliStatCommandProcessor, CliTacCommandProcessor, CliTailCommandProcessor, CliTeeCommandProcessor, CliTouchCommandProcessor, CliTrCommandProcessor, CliTreeCommandProcessor, CliUniqCommandProcessor, CliWcCommandProcessor, CliXargsCommandProcessor, FilePathCompletionProvider, IFileSystemService_TOKEN, IndexedDbFileSystemService, ServerFileSystemService, VirtualFsFileTransferService, executeScript, filesModule };
6436
+ export { ANONYMOUS_OWNERSHIP, CliAwkCommandProcessor, CliBasenameCommandProcessor, CliCatCommandProcessor, CliCdCommandProcessor, CliChmodCommandProcessor, CliChownCommandProcessor, CliCpCommandProcessor, CliCutCommandProcessor, CliDiffCommandProcessor, CliDirnameCommandProcessor, CliDuCommandProcessor, CliEchoCommandProcessor, CliFilesPermissions, CliFindCommandProcessor, CliGrepCommandProcessor, CliHeadCommandProcessor, CliLnCommandProcessor, CliLsCommandProcessor, CliMkdirCommandProcessor, CliMvCommandProcessor, CliPasteCommandProcessor, CliPwdCommandProcessor, CliRmCommandProcessor, CliRmdirCommandProcessor, CliSedCommandProcessor, CliShCommandProcessor, CliSortCommandProcessor, CliStatCommandProcessor, CliTacCommandProcessor, CliTailCommandProcessor, CliTeeCommandProcessor, CliTouchCommandProcessor, CliTrCommandProcessor, CliTreeCommandProcessor, CliUniqCommandProcessor, CliWcCommandProcessor, CliXargsCommandProcessor, FilePathCompletionProvider, HOME_DIR_PERMISSIONS, IFileSystemService_TOKEN, IndexedDbFileSystemService, SYSTEM_OWNERSHIP, ServerFileSystemService, VirtualFsFileTransferService, createFileAccessPolicy, executeScript, filesModule, followUserAccounts, followUserSession };
5163
6437
  //# sourceMappingURL=public-api.mjs.map
5164
6438
  //# sourceMappingURL=public-api.mjs.map